Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ffe489e32 | ||
|
|
acbddf88d2 | ||
|
|
23ac6392bb | ||
|
|
d2fd454d6e | ||
|
|
182eb504de | ||
|
|
98fa5dfcc1 | ||
|
|
429520d3f9 | ||
|
|
fbfe1da7da | ||
|
|
c047bdda8c | ||
|
|
a33e22559b | ||
|
|
1318604f32 | ||
|
|
43a5f72b01 | ||
|
|
d97820c232 | ||
|
|
03cd7c1456 | ||
|
|
b1b37366e8 | ||
|
|
5cd9377bba | ||
|
|
a3e1cc8393 | ||
|
|
1810a12cf3 | ||
|
|
c3cd8c48b5 | ||
|
|
2502a8def0 | ||
|
|
54cd804319 | ||
|
|
1b0a1e938e | ||
|
|
69b035c6e1 | ||
|
|
8164c56d33 | ||
|
|
098f2badec | ||
|
|
d2a4054120 | ||
|
|
7baf503d60 | ||
|
|
d9622d0fac | ||
|
|
ddba5a3eab | ||
|
|
0bfb90cfc1 | ||
|
|
c454a72f08 | ||
|
|
34fd328444 | ||
|
|
738dd0645d | ||
|
|
e997f0d4ff | ||
|
|
e137436656 | ||
|
|
1649b9bffb | ||
|
|
c990ab3216 | ||
|
|
e6d3f20c36 | ||
|
|
79d5bc942d | ||
|
|
d652fc1d1c | ||
|
|
22a1412242 | ||
|
|
0c3c0765a3 | ||
|
+17 |
68ed54e398 | ||
|
|
a5dcdd5bd9 | ||
|
|
5eafbf9e51 | ||
|
|
a1317f0e01 | ||
|
|
3529849bca | ||
|
|
49bea34576 | ||
|
|
f9a44c4da4 | ||
|
|
4ec75d4d8c | ||
|
|
93ed05b054 | ||
|
|
195b1472e4 | ||
|
|
340006ceea | ||
|
|
d80e627899 | ||
|
|
336ec34125 | ||
|
|
5f72c05623 | ||
|
|
33188815da | ||
|
|
0064c1866e | ||
|
|
371d816ce7 | ||
|
|
5f54308d2b | ||
|
|
6f7b8ce433 | ||
|
|
69ffd29bb5 | ||
|
|
f4cc87144d | ||
|
|
c86e2d6840 | ||
|
|
5f072f87bb | ||
|
|
eb3e57577c | ||
|
|
f29d85565a | ||
|
|
ae72344317 | ||
|
|
6565a6ce18 | ||
|
|
e26035c3b3 | ||
|
|
e474cc76ad | ||
|
|
43eaa84a8f | ||
|
|
2cd4175689 | ||
|
|
d6a83fe6a1 | ||
|
|
3c92cb7c2b | ||
|
|
b83888c74a | ||
|
|
787d0d21c9 | ||
|
|
7b26b184d1 | ||
|
|
f17ad56def | ||
|
|
de438d00e1 | ||
|
|
6787169583 | ||
|
|
291e1a392b | ||
|
|
7c5aa3685f | ||
|
|
9c03914edd | ||
|
|
c980ee10c0 | ||
|
|
fa4d2bc09a | ||
|
|
7faf7e50fa | ||
|
|
bb201ef9c1 | ||
|
|
82dbcfbb9b | ||
|
|
9fd8fd89ed | ||
|
|
3a2370ea1f | ||
|
|
e7db714091 | ||
|
|
c6b4a2b141 | ||
|
|
7459463a3c | ||
|
|
c9bf436895 | ||
|
|
f8dbf171e1 | ||
|
|
e59c717dc0 | ||
|
|
f2bffe2dc6 | ||
|
|
5d827df08c | ||
|
|
bdb3c887f3 | ||
|
|
f902e8aca9 | ||
|
|
65a3eeca76 | ||
|
|
f72538d30f | ||
|
|
88c5fb46ad | ||
|
|
8e2f9546a5 | ||
|
|
f2f4bd5230 | ||
|
|
f3cc30d0c2 | ||
|
|
ba7c75aff4 | ||
|
|
a53d73ef51 | ||
|
|
c2a63cf425 | ||
|
|
c3456adc2b | ||
|
|
179b569769 | ||
|
|
341adaa07d | ||
|
|
feebfe82fa | ||
|
|
1e72416d55 | ||
|
|
959d14f075 | ||
|
|
8f6fcee428 | ||
|
|
651f17f1c6 | ||
|
|
fde449e738 | ||
|
|
85e9121745 | ||
|
|
12f1fd485e | ||
|
|
b49e8a2355 | ||
|
|
d908bc6785 | ||
|
|
4ae79d92ae | ||
|
|
85d9b5b83d | ||
|
|
058a81d554 | ||
|
|
e4c6ce5836 | ||
|
|
250979e271 | ||
|
|
731743b618 | ||
|
|
04c4aec0d8 | ||
|
|
f63df148ad | ||
|
|
6f0be94bd6 | ||
|
|
e47ed13be8 | ||
|
|
a62c8b54cf | ||
|
|
1e34038b1e | ||
|
|
d4765938a8 | ||
|
|
476e1505ee | ||
|
|
d3491ecccf | ||
|
|
cddd5e7d15 | ||
|
|
f4c9becce2 | ||
|
|
7629d89356 | ||
|
|
9fdb5295cb | ||
|
|
21657a3fa6 | ||
|
|
bc11875081 | ||
|
|
31b3219169 | ||
|
|
d8a99fcb07 | ||
|
|
8016d271aa | ||
|
|
8f1bef7f46 | ||
|
|
e6db57e031 | ||
|
|
daf749e6be | ||
|
|
53d167eda5 | ||
|
|
d74856f675 | ||
|
|
f8386843d4 | ||
|
|
2277a94c91 | ||
|
|
3b8b539efc | ||
|
|
7253b9fa6d | ||
|
|
9a1f89486d | ||
|
|
a6614e1bfb | ||
|
|
ae5b1a4b02 | ||
|
|
9fcaace8ed | ||
|
|
2ab7945130 | ||
|
|
a8db2fda18 | ||
|
|
80f2980755 | ||
|
|
2d8b0ff08c | ||
|
|
991e03970e | ||
|
|
64d343b765 | ||
|
|
2534c532b7 | ||
|
|
8757a80fda | ||
|
|
d599a07a66 | ||
|
|
861e4394df | ||
|
|
5d18184e46 | ||
|
|
050408639b | ||
|
|
b0397da19e | ||
|
|
d42065e338 | ||
|
|
30bcc38c04 | ||
|
|
e8e98128d2 | ||
|
|
e1a879a5f6 | ||
|
|
4fd336c26c | ||
|
|
fca8470637 | ||
|
|
76f2fd3b76 | ||
|
|
06af6be14b | ||
|
|
c87da6d5cc | ||
|
|
6c8d2c89fe | ||
|
|
8d9e70c796 | ||
|
|
f4725b98b0 | ||
|
|
75414b89f9 | ||
|
|
dd1c653301 | ||
|
|
0f73a1cf13 | ||
|
|
8ffe53536d | ||
|
|
6177ccd333 |
@@ -5,6 +5,7 @@ frontend/dist
|
||||
frontend/build
|
||||
frontend/.vite
|
||||
frontend/.tauri
|
||||
frontend/src-tauri/target
|
||||
|
||||
# Gradle build artifacts
|
||||
.gradle
|
||||
|
||||
@@ -6,22 +6,27 @@ app: &app
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
openapi: &openapi
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
- *build
|
||||
- *app
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- 'app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*'
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
- testing/**
|
||||
- build.gradle
|
||||
docker: &docker
|
||||
- Dockerfile
|
||||
- Dockerfile.fat
|
||||
- Dockerfile.ultra-lite
|
||||
- ".github/workflows/build.yml"
|
||||
- scripts/init.sh
|
||||
- scripts/init-without-ocr.sh
|
||||
- exampleYmlFiles/**
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- *build
|
||||
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
- "testing/**/!(requirements*.txt|requirements*.in)*"
|
||||
- *docker
|
||||
- gradle.properties
|
||||
- gradlew
|
||||
- gradlew.bat
|
||||
@@ -29,7 +34,6 @@ project: &project
|
||||
- settings.gradle
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- testing/**
|
||||
|
||||
frontend: &frontend
|
||||
- frontend/**
|
||||
|
||||
@@ -1 +1 @@
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"label_changer": [
|
||||
"Frooodle",
|
||||
"Ludy87",
|
||||
"balazs-szucs"
|
||||
],
|
||||
"repo_devs": [
|
||||
"Frooodle",
|
||||
"sf298",
|
||||
|
||||
@@ -184,3 +184,6 @@
|
||||
- name: "codex"
|
||||
color: "ededed"
|
||||
description: "chatgpt AI generated code"
|
||||
- name: "break-change"
|
||||
color: "FF0000"
|
||||
description: "This PR introduces a breaking API change."
|
||||
|
||||
@@ -27,6 +27,10 @@ Closes #(issue_number)
|
||||
- [ ] 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)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
changelog:
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- break-change
|
||||
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- Bug
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes .properties files for localization checks. It compares translation files in a branch with
|
||||
a reference file to ensure consistency. The script performs two main checks:
|
||||
1. Verifies that the number of lines (including comments and empty lines) in the translation files matches the reference file.
|
||||
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
|
||||
|
||||
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
|
||||
adjusting the format.
|
||||
|
||||
Usage:
|
||||
python check_language_properties.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_de_DE.properties src\main\resources\messages_uk_UA.properties
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
|
||||
|
||||
def find_duplicate_keys(file_path):
|
||||
"""
|
||||
Identifies duplicate keys in a .properties file.
|
||||
:param file_path: Path to the .properties file.
|
||||
:return: List of tuples (key, first_occurrence_line, duplicate_line).
|
||||
"""
|
||||
keys = {}
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
for line_number, line in enumerate(file, start=1):
|
||||
stripped_line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not stripped_line or stripped_line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Split the line into key and value
|
||||
if "=" in stripped_line:
|
||||
key, _ = stripped_line.split("=", 1)
|
||||
key = key.strip()
|
||||
|
||||
# Check if the key already exists
|
||||
if key in keys:
|
||||
duplicates.append((key, keys[key], line_number))
|
||||
else:
|
||||
keys[key] = line_number
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for properties files (e.g., 200 KB)
|
||||
MAX_FILE_SIZE = 200 * 1024
|
||||
|
||||
|
||||
def parse_properties_file(file_path):
|
||||
"""
|
||||
Parses a .properties file and returns a structured list of its contents.
|
||||
:param file_path: Path to the .properties file.
|
||||
:return: List of dictionaries representing each line in the file.
|
||||
"""
|
||||
properties_list = []
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
for line_number, line in enumerate(file, start=1):
|
||||
stripped_line = line.strip()
|
||||
|
||||
# Handle empty lines
|
||||
if not stripped_line:
|
||||
properties_list.append(
|
||||
{"line_number": line_number, "type": "empty", "content": ""}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle comments
|
||||
if stripped_line.startswith("#"):
|
||||
properties_list.append(
|
||||
{
|
||||
"line_number": line_number,
|
||||
"type": "comment",
|
||||
"content": stripped_line,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle key-value pairs
|
||||
match = re.match(r"^([^=]+)=(.*)$", line)
|
||||
if match:
|
||||
key, value = match.groups()
|
||||
properties_list.append(
|
||||
{
|
||||
"line_number": line_number,
|
||||
"type": "entry",
|
||||
"key": key.strip(),
|
||||
"value": value.strip(),
|
||||
}
|
||||
)
|
||||
|
||||
return properties_list
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the file in their original format.
|
||||
:param file_path: Path to the .properties file.
|
||||
:param updated_properties: List of updated properties to write.
|
||||
"""
|
||||
updated_lines = {entry["line_number"]: entry for entry in updated_properties}
|
||||
|
||||
# Sort lines by their numbers and retain comments and empty lines
|
||||
all_lines = sorted(set(updated_lines.keys()))
|
||||
|
||||
original_format = []
|
||||
for line in all_lines:
|
||||
if line in updated_lines:
|
||||
entry = updated_lines[line]
|
||||
else:
|
||||
entry = None
|
||||
ref_entry = updated_lines[line]
|
||||
if ref_entry["type"] in ["comment", "empty"]:
|
||||
original_format.append(ref_entry)
|
||||
elif entry is None:
|
||||
# Add missing entries from the reference file
|
||||
original_format.append(ref_entry)
|
||||
elif entry["type"] == "entry":
|
||||
# Replace entries with those from the current JSON
|
||||
original_format.append(entry)
|
||||
|
||||
# Write the updated content back to the file
|
||||
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
|
||||
for entry in original_format:
|
||||
if entry["type"] == "comment":
|
||||
file.write(f"{entry['content']}\n")
|
||||
elif entry["type"] == "empty":
|
||||
file.write(f"{entry['content']}\n")
|
||||
elif entry["type"] == "entry":
|
||||
file.write(f"{entry['key']}={entry['value']}\n")
|
||||
|
||||
|
||||
def update_missing_keys(reference_file, file_list, branch=""):
|
||||
"""
|
||||
Updates missing keys in the translation files based on the reference file.
|
||||
:param reference_file: Path to the reference .properties file.
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_properties_file(reference_file)
|
||||
for file_path in file_list:
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".properties")
|
||||
or not basename_current_file.startswith("messages_")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_properties_file(os.path.join(branch, file_path))
|
||||
updated_properties = []
|
||||
for ref_entry in reference_properties:
|
||||
ref_entry_copy = copy.deepcopy(ref_entry)
|
||||
for current_entry in current_properties:
|
||||
if current_entry["type"] == "entry":
|
||||
if ref_entry_copy["type"] != "entry":
|
||||
continue
|
||||
if ref_entry_copy["key"].lower() == current_entry["key"].lower():
|
||||
ref_entry_copy["value"] = current_entry["value"]
|
||||
updated_properties.append(ref_entry_copy)
|
||||
write_json_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
update_missing_keys(reference_file, file_list, branch)
|
||||
|
||||
|
||||
def read_properties(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
return file.read().splitlines()
|
||||
return [""]
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = reference_file.split("/")[0]
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_lines = read_properties(reference_file)
|
||||
has_differences = False
|
||||
|
||||
only_reference_file = True
|
||||
|
||||
file_arr = file_list
|
||||
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
|
||||
)
|
||||
|
||||
for file_path in file_arr:
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
absolute_path = os.path.abspath(file_normpath)
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.startswith(base_dir):
|
||||
raise ValueError(f"Unsafe file found: {file_normpath}")
|
||||
# Verify file size before processing
|
||||
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"The file {file_normpath} is too large and could pose a security risk."
|
||||
)
|
||||
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
|
||||
if (
|
||||
basename_current_file == basename_reference_file
|
||||
or (
|
||||
# only local windows command
|
||||
not file_normpath.startswith(
|
||||
os.path.join(
|
||||
"", "app", "core", "src", "main", "resources", "messages_"
|
||||
)
|
||||
)
|
||||
and not file_normpath.startswith(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"app",
|
||||
"core",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_",
|
||||
)
|
||||
)
|
||||
)
|
||||
or not file_normpath.endswith(".properties")
|
||||
or not basename_current_file.startswith("messages_")
|
||||
):
|
||||
continue
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{basename_current_file}`")
|
||||
current_lines = read_properties(os.path.join(branch, file_path))
|
||||
reference_line_count = len(reference_lines)
|
||||
current_line_count = len(current_lines)
|
||||
|
||||
if reference_line_count != current_line_count:
|
||||
report.append("")
|
||||
report.append("1. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
has_differences = True
|
||||
if reference_line_count > current_line_count:
|
||||
report.append(
|
||||
f" - **_Mismatched line count_**: {reference_line_count} (reference) vs {current_line_count} (current). Comments, empty lines, or translation strings are missing."
|
||||
)
|
||||
elif reference_line_count < current_line_count:
|
||||
report.append(
|
||||
f" - **_Too many lines_**: {reference_line_count} (reference) vs {current_line_count} (current). Please verify if there is an additional line that needs to be removed."
|
||||
)
|
||||
else:
|
||||
report.append("1. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
# Check for missing or extra keys
|
||||
current_keys = []
|
||||
reference_keys = []
|
||||
for line in current_lines:
|
||||
if not line.startswith("#") and line != "" and "=" in line:
|
||||
key, _ = line.split("=", 1)
|
||||
current_keys.append(key)
|
||||
for line in reference_lines:
|
||||
if not line.startswith("#") and line != "" and "=" in line:
|
||||
key, _ = line.split("=", 1)
|
||||
reference_keys.append(key)
|
||||
|
||||
current_keys_set = set(current_keys)
|
||||
reference_keys_set = set(reference_keys)
|
||||
missing_keys = current_keys_set.difference(reference_keys_set)
|
||||
extra_keys = reference_keys_set.difference(current_keys_set)
|
||||
missing_keys_list = list(missing_keys)
|
||||
extra_keys_list = list(extra_keys)
|
||||
|
||||
if missing_keys_list or extra_keys_list:
|
||||
has_differences = True
|
||||
missing_keys_str = "`, `".join(missing_keys_list)
|
||||
extra_keys_str = "`, `".join(extra_keys_list)
|
||||
report.append("2. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
if missing_keys_list:
|
||||
spaces_keys_list = []
|
||||
for key in missing_keys_list:
|
||||
if " " in key:
|
||||
spaces_keys_list.append(key)
|
||||
if spaces_keys_list:
|
||||
spaces_keys_str = "`, `".join(spaces_keys_list)
|
||||
report.append(
|
||||
f" - **_Keys containing unnecessary spaces_**: `{spaces_keys_str}`!"
|
||||
)
|
||||
report.append(
|
||||
f" - **_Extra keys in `{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
f" - **_Missing keys in `{basename_reference_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_current_file}`_**."
|
||||
)
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
if find_duplicate_keys(os.path.join(branch, file_normpath)):
|
||||
has_differences = True
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at line {first}, duplicate at `line {duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
os.path.join(branch, file_normpath)
|
||||
)
|
||||
]
|
||||
)
|
||||
report.append("3. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
report.append(" - duplicate entries were found:")
|
||||
report.append(output)
|
||||
else:
|
||||
report.append("3. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
report.append("")
|
||||
report.append("---")
|
||||
report.append("")
|
||||
if has_differences:
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"Thanks @{actor} for your help in keeping the translations up to date."
|
||||
)
|
||||
|
||||
if not only_reference_file:
|
||||
print("\n".join(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys")
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
help="Actor from PR.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-file",
|
||||
required=True,
|
||||
help="Path to the reference file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--branch",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Branch name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-file",
|
||||
type=str,
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--files",
|
||||
nargs="+",
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Sanitize --actor input to avoid injection attacks
|
||||
if args.actor:
|
||||
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
|
||||
|
||||
# Sanitize --branch input to avoid injection attacks
|
||||
if args.branch:
|
||||
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
|
||||
|
||||
file_list = args.files
|
||||
if file_list is None:
|
||||
if args.check_file:
|
||||
file_list = [args.check_file]
|
||||
else:
|
||||
file_list = glob.glob(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"app",
|
||||
"core",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_*.properties",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
else:
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
|
||||
Description: This script processes TOML translation files for localization checks. It compares translation files in a branch with
|
||||
a reference file to ensure consistency. The script performs two main checks:
|
||||
1. Verifies that the number of translation keys in the translation files matches the reference file.
|
||||
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
|
||||
@@ -9,23 +9,23 @@ The script also provides functionality to update the translation files to match
|
||||
adjusting the format.
|
||||
|
||||
Usage:
|
||||
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
import json
|
||||
import tomllib # Python 3.11+ (stdlib)
|
||||
import tomli_w # For writing TOML files
|
||||
|
||||
|
||||
def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
"""
|
||||
Identifies duplicate keys in a JSON file (including nested keys).
|
||||
:param file_path: Path to the JSON file.
|
||||
Identifies duplicate keys in a TOML file (including nested keys).
|
||||
:param file_path: Path to the TOML file.
|
||||
:param keys: Dictionary to track keys (used for recursion).
|
||||
:param prefix: Prefix for nested keys.
|
||||
:return: List of tuples (key, first_occurrence_path, duplicate_path).
|
||||
@@ -35,8 +35,9 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
# Load TOML file
|
||||
with open(file_path, "rb") as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
for key, value in obj.items():
|
||||
@@ -54,18 +55,18 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for JSON files (e.g., 500 KB)
|
||||
# Maximum size for TOML files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
|
||||
|
||||
def parse_json_file(file_path):
|
||||
def parse_toml_file(file_path):
|
||||
"""
|
||||
Parses a JSON translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the JSON file.
|
||||
Parses a TOML translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the TOML file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
with open(file_path, "rb") as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
items = {}
|
||||
@@ -99,38 +100,37 @@ def unflatten_dict(d, sep="."):
|
||||
return result
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
def write_toml_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the JSON file.
|
||||
:param file_path: Path to the JSON file.
|
||||
Writes updated properties back to the TOML file.
|
||||
:param file_path: Path to the TOML file.
|
||||
:param updated_properties: Dictionary of updated properties to write.
|
||||
"""
|
||||
nested_data = unflatten_dict(updated_properties)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
|
||||
json.dump(nested_data, file, ensure_ascii=False, indent=2)
|
||||
file.write("\n") # Add trailing newline
|
||||
with open(file_path, "wb") as file:
|
||||
tomli_w.dump(nested_data, file)
|
||||
|
||||
|
||||
def update_missing_keys(reference_file, file_list, branch=""):
|
||||
"""
|
||||
Updates missing keys in the translation files based on the reference file.
|
||||
:param reference_file: Path to the reference JSON file.
|
||||
:param reference_file: Path to the reference TOML file.
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_json_file(reference_file)
|
||||
reference_properties = parse_toml_file(reference_file)
|
||||
|
||||
for file_path in file_list:
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".json")
|
||||
or not file_path.endswith(".toml")
|
||||
or not os.path.dirname(file_path).endswith("locales")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_json_file(os.path.join(branch, file_path))
|
||||
current_properties = parse_toml_file(os.path.join(branch, file_path))
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
@@ -141,16 +141,16 @@ def update_missing_keys(reference_file, file_list, branch=""):
|
||||
# Add missing key with reference value
|
||||
updated_properties[ref_key] = ref_value
|
||||
|
||||
write_json_file(os.path.join(branch, file_path), updated_properties)
|
||||
write_toml_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
update_missing_keys(reference_file, file_list, branch)
|
||||
|
||||
|
||||
def read_json_keys(file_path):
|
||||
def read_toml_keys(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
return parse_json_file(file_path)
|
||||
return parse_toml_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_keys = read_json_keys(reference_file)
|
||||
reference_keys = read_toml_keys(reference_file)
|
||||
has_differences = False
|
||||
|
||||
only_reference_file = True
|
||||
@@ -191,18 +191,18 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
|
||||
locale_dir = os.path.basename(os.path.dirname(file_normpath))
|
||||
|
||||
if (
|
||||
basename_current_file == basename_reference_file
|
||||
and locale_dir == "en-GB"
|
||||
):
|
||||
if basename_current_file == basename_reference_file and locale_dir == "en-GB":
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
|
||||
if (
|
||||
not file_normpath.endswith(".toml")
|
||||
or basename_current_file != "translation.toml"
|
||||
):
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
current_keys = read_json_keys(os.path.join(branch, file_path))
|
||||
current_keys = read_toml_keys(os.path.join(branch, file_path))
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
@@ -272,7 +272,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
@@ -286,7 +286,9 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Find missing keys in TOML translation files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
@@ -337,9 +339,9 @@ if __name__ == "__main__":
|
||||
"public",
|
||||
"locales",
|
||||
"*",
|
||||
"translation.json",
|
||||
"translation.toml",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
else:
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
@@ -6,3 +6,4 @@ pillow
|
||||
unoserver
|
||||
opencv-python-headless
|
||||
pre-commit
|
||||
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
|
||||
|
||||
@@ -4,201 +4,98 @@
|
||||
#
|
||||
# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in'
|
||||
#
|
||||
brotli==1.1.0 \
|
||||
--hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \
|
||||
--hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \
|
||||
--hash=sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354 \
|
||||
--hash=sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419 \
|
||||
--hash=sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a \
|
||||
--hash=sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128 \
|
||||
--hash=sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c \
|
||||
--hash=sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088 \
|
||||
--hash=sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9 \
|
||||
--hash=sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a \
|
||||
--hash=sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3 \
|
||||
--hash=sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757 \
|
||||
--hash=sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2 \
|
||||
--hash=sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438 \
|
||||
--hash=sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578 \
|
||||
--hash=sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b \
|
||||
--hash=sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b \
|
||||
--hash=sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68 \
|
||||
--hash=sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0 \
|
||||
--hash=sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d \
|
||||
--hash=sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943 \
|
||||
--hash=sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd \
|
||||
--hash=sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409 \
|
||||
--hash=sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28 \
|
||||
--hash=sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da \
|
||||
--hash=sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50 \
|
||||
--hash=sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f \
|
||||
--hash=sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0 \
|
||||
--hash=sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547 \
|
||||
--hash=sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180 \
|
||||
--hash=sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0 \
|
||||
--hash=sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d \
|
||||
--hash=sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a \
|
||||
--hash=sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb \
|
||||
--hash=sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112 \
|
||||
--hash=sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc \
|
||||
--hash=sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2 \
|
||||
--hash=sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265 \
|
||||
--hash=sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327 \
|
||||
--hash=sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95 \
|
||||
--hash=sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec \
|
||||
--hash=sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd \
|
||||
--hash=sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c \
|
||||
--hash=sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38 \
|
||||
--hash=sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914 \
|
||||
--hash=sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0 \
|
||||
--hash=sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a \
|
||||
--hash=sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7 \
|
||||
--hash=sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368 \
|
||||
--hash=sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c \
|
||||
--hash=sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0 \
|
||||
--hash=sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f \
|
||||
--hash=sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451 \
|
||||
--hash=sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f \
|
||||
--hash=sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8 \
|
||||
--hash=sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e \
|
||||
--hash=sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248 \
|
||||
--hash=sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c \
|
||||
--hash=sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91 \
|
||||
--hash=sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724 \
|
||||
--hash=sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7 \
|
||||
--hash=sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966 \
|
||||
--hash=sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9 \
|
||||
--hash=sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97 \
|
||||
--hash=sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d \
|
||||
--hash=sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5 \
|
||||
--hash=sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf \
|
||||
--hash=sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac \
|
||||
--hash=sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b \
|
||||
--hash=sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951 \
|
||||
--hash=sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74 \
|
||||
--hash=sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648 \
|
||||
--hash=sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60 \
|
||||
--hash=sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c \
|
||||
--hash=sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1 \
|
||||
--hash=sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8 \
|
||||
--hash=sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d \
|
||||
--hash=sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc \
|
||||
--hash=sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61 \
|
||||
--hash=sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460 \
|
||||
--hash=sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751 \
|
||||
--hash=sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9 \
|
||||
--hash=sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2 \
|
||||
--hash=sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0 \
|
||||
--hash=sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1 \
|
||||
--hash=sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474 \
|
||||
--hash=sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75 \
|
||||
--hash=sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5 \
|
||||
--hash=sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f \
|
||||
--hash=sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2 \
|
||||
--hash=sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f \
|
||||
--hash=sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb \
|
||||
--hash=sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6 \
|
||||
--hash=sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9 \
|
||||
--hash=sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111 \
|
||||
--hash=sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2 \
|
||||
--hash=sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01 \
|
||||
--hash=sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467 \
|
||||
--hash=sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619 \
|
||||
--hash=sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf \
|
||||
--hash=sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408 \
|
||||
--hash=sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579 \
|
||||
--hash=sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84 \
|
||||
--hash=sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7 \
|
||||
--hash=sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c \
|
||||
--hash=sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284 \
|
||||
--hash=sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52 \
|
||||
--hash=sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b \
|
||||
--hash=sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59 \
|
||||
--hash=sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752 \
|
||||
--hash=sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1 \
|
||||
--hash=sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80 \
|
||||
--hash=sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839 \
|
||||
--hash=sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0 \
|
||||
--hash=sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2 \
|
||||
--hash=sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3 \
|
||||
--hash=sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64 \
|
||||
--hash=sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089 \
|
||||
--hash=sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643 \
|
||||
--hash=sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b \
|
||||
--hash=sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e \
|
||||
--hash=sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985 \
|
||||
--hash=sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596 \
|
||||
--hash=sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2 \
|
||||
--hash=sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064
|
||||
# via fonttools
|
||||
cffi==1.17.1 \
|
||||
--hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
|
||||
--hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
|
||||
--hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
|
||||
--hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
|
||||
--hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
|
||||
--hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
|
||||
--hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
|
||||
--hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
|
||||
--hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
|
||||
--hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
|
||||
--hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
|
||||
--hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
|
||||
--hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
|
||||
--hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
|
||||
--hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
|
||||
--hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
|
||||
--hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
|
||||
--hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
|
||||
--hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
|
||||
--hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
|
||||
--hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
|
||||
--hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
|
||||
--hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
|
||||
--hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
|
||||
--hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
|
||||
--hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
|
||||
--hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
|
||||
--hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
|
||||
--hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
|
||||
--hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
|
||||
--hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
|
||||
--hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
|
||||
--hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
|
||||
--hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
|
||||
--hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
|
||||
--hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
|
||||
--hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
|
||||
--hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
|
||||
--hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
|
||||
--hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
|
||||
--hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
|
||||
--hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
|
||||
--hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
|
||||
--hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
|
||||
--hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
|
||||
--hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
|
||||
--hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
|
||||
--hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
|
||||
--hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
|
||||
--hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
|
||||
--hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
|
||||
--hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
|
||||
--hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
|
||||
--hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
|
||||
--hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
|
||||
--hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
|
||||
--hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
|
||||
--hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
|
||||
--hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
|
||||
--hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
|
||||
--hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
|
||||
--hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
|
||||
--hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
|
||||
--hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
|
||||
--hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
|
||||
--hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
|
||||
--hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
|
||||
# WARNING: pip install will require the following package to be hashed.
|
||||
# Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip
|
||||
# CVE-2025-6176 mitigation: pin brotli to a specific commit
|
||||
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
|
||||
# via
|
||||
# -r .github/scripts/requirements_dev.in
|
||||
# fonttools
|
||||
cffi==2.0.0 \
|
||||
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
|
||||
--hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
|
||||
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
|
||||
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
|
||||
--hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
|
||||
--hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
|
||||
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
|
||||
--hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
|
||||
--hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
|
||||
--hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
|
||||
--hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
|
||||
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
|
||||
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
|
||||
--hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
|
||||
--hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
|
||||
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
|
||||
--hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
|
||||
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
|
||||
--hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
|
||||
--hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
|
||||
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
|
||||
--hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
|
||||
--hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
|
||||
--hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
|
||||
--hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
|
||||
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
|
||||
--hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
|
||||
--hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
|
||||
--hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
|
||||
--hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
|
||||
--hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
|
||||
--hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
|
||||
--hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
|
||||
--hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
|
||||
--hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
|
||||
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
|
||||
--hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
|
||||
--hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
|
||||
--hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
|
||||
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
|
||||
--hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
|
||||
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
|
||||
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
|
||||
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
|
||||
--hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
|
||||
--hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
|
||||
--hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
|
||||
--hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
|
||||
--hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
|
||||
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
|
||||
--hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
|
||||
--hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
|
||||
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
|
||||
--hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
|
||||
--hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
|
||||
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
|
||||
--hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
|
||||
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
|
||||
--hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
|
||||
--hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
|
||||
--hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
|
||||
--hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
|
||||
--hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
|
||||
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
|
||||
--hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
|
||||
--hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
|
||||
--hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
|
||||
--hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
|
||||
--hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
|
||||
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
|
||||
--hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
|
||||
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
|
||||
--hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
|
||||
--hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
|
||||
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
|
||||
--hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
|
||||
--hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
|
||||
--hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
|
||||
--hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
|
||||
--hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
|
||||
--hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
|
||||
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
|
||||
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
|
||||
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
|
||||
# via weasyprint
|
||||
cfgv==3.4.0 \
|
||||
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
|
||||
@@ -212,57 +109,73 @@ distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
filelock==3.18.0 \
|
||||
--hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \
|
||||
--hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de
|
||||
filelock==3.20.0 \
|
||||
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
|
||||
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
|
||||
# via virtualenv
|
||||
fonttools==4.59.0 \
|
||||
--hash=sha256:052444a5d0151878e87e3e512a1aa1a0ab35ee4c28afde0a778e23b0ace4a7de \
|
||||
--hash=sha256:169b99a2553a227f7b5fea8d9ecd673aa258617f466b2abc6091fe4512a0dcd0 \
|
||||
--hash=sha256:209b75943d158f610b78320eacb5539aa9e920bee2c775445b2846c65d20e19d \
|
||||
--hash=sha256:21e606b2d38fed938dde871c5736822dd6bda7a4631b92e509a1f5cd1b90c5df \
|
||||
--hash=sha256:241313683afd3baacb32a6bd124d0bce7404bc5280e12e291bae1b9bba28711d \
|
||||
--hash=sha256:26731739daa23b872643f0e4072d5939960237d540c35c14e6a06d47d71ca8fe \
|
||||
--hash=sha256:2e7cf8044ce2598bb87e44ba1d2c6e45d7a8decf56055b92906dc53f67c76d64 \
|
||||
--hash=sha256:31003b6a10f70742a63126b80863ab48175fb8272a18ca0846c0482968f0588e \
|
||||
--hash=sha256:332bfe685d1ac58ca8d62b8d6c71c2e52a6c64bc218dc8f7825c9ea51385aa01 \
|
||||
--hash=sha256:37c377f7cb2ab2eca8a0b319c68146d34a339792f9420fca6cd49cf28d370705 \
|
||||
--hash=sha256:37e01c6ec0c98599778c2e688350d624fa4770fbd6144551bd5e032f1199171c \
|
||||
--hash=sha256:401b1941ce37e78b8fd119b419b617277c65ae9417742a63282257434fd68ea2 \
|
||||
--hash=sha256:4536f2695fe5c1ffb528d84a35a7d3967e5558d2af58b4775e7ab1449d65767b \
|
||||
--hash=sha256:4c908a7036f0f3677f8afa577bcd973e3e20ddd2f7c42a33208d18bee95cdb6f \
|
||||
--hash=sha256:51ab1ff33c19e336c02dee1e9fd1abd974a4ca3d8f7eef2a104d0816a241ce97 \
|
||||
--hash=sha256:524133c1be38445c5c0575eacea42dbd44374b310b1ffc4b60ff01d881fabb96 \
|
||||
--hash=sha256:57bb7e26928573ee7c6504f54c05860d867fd35e675769f3ce01b52af38d48e2 \
|
||||
--hash=sha256:60f6665579e909b618282f3c14fa0b80570fbf1ee0e67678b9a9d43aa5d67a37 \
|
||||
--hash=sha256:62224a9bb85b4b66d1b46d45cbe43d71cbf8f527d332b177e3b96191ffbc1e64 \
|
||||
--hash=sha256:6770d7da00f358183d8fd5c4615436189e4f683bdb6affb02cad3d221d7bb757 \
|
||||
--hash=sha256:6801aeddb6acb2c42eafa45bc1cb98ba236871ae6f33f31e984670b749a8e58e \
|
||||
--hash=sha256:70d6b3ceaa9cc5a6ac52884f3b3d9544e8e231e95b23f138bdb78e6d4dc0eae3 \
|
||||
--hash=sha256:78813b49d749e1bb4db1c57f2d4d7e6db22c253cb0a86ad819f5dc197710d4b2 \
|
||||
--hash=sha256:841b2186adce48903c0fef235421ae21549020eca942c1da773ac380b056ab3c \
|
||||
--hash=sha256:84fc186980231a287b28560d3123bd255d3c6b6659828c642b4cf961e2b923d0 \
|
||||
--hash=sha256:885bde7d26e5b40e15c47bd5def48b38cbd50830a65f98122a8fb90962af7cd1 \
|
||||
--hash=sha256:8b4309a2775e4feee7356e63b163969a215d663399cce1b3d3b65e7ec2d9680e \
|
||||
--hash=sha256:8d77f92438daeaddc05682f0f3dac90c5b9829bcac75b57e8ce09cb67786073c \
|
||||
--hash=sha256:902425f5afe28572d65d2bf9c33edd5265c612ff82c69e6f83ea13eafc0dcbea \
|
||||
--hash=sha256:9bcc1e77fbd1609198966ded6b2a9897bd6c6bcbd2287a2fc7d75f1a254179c5 \
|
||||
--hash=sha256:a408c3c51358c89b29cfa5317cf11518b7ce5de1717abb55c5ae2d2921027de6 \
|
||||
--hash=sha256:a9bf8adc9e1f3012edc8f09b08336272aec0c55bc677422273e21280db748f7c \
|
||||
--hash=sha256:b818db35879d2edf7f46c7e729c700a0bce03b61b9412f5a7118406687cb151d \
|
||||
--hash=sha256:b8974b2a266b54c96709bd5e239979cddfd2dbceed331aa567ea1d7c4a2202db \
|
||||
--hash=sha256:be392ec3529e2f57faa28709d60723a763904f71a2b63aabe14fee6648fe3b14 \
|
||||
--hash=sha256:d3972b13148c1d1fbc092b27678a33b3080d1ac0ca305742b0119b75f9e87e38 \
|
||||
--hash=sha256:d40dcf533ca481355aa7b682e9e079f766f35715defa4929aeb5597f9604272e \
|
||||
--hash=sha256:e93df708c69a193fc7987192f94df250f83f3851fda49413f02ba5dded639482 \
|
||||
--hash=sha256:efd7e6660674e234e29937bc1481dceb7e0336bfae75b856b4fb272b5093c5d4 \
|
||||
--hash=sha256:f9b3a78f69dcbd803cf2fb3f972779875b244c1115481dfbdd567b2c22b31f6b \
|
||||
--hash=sha256:fa39475eaccb98f9199eccfda4298abaf35ae0caec676ffc25b3a5e224044464 \
|
||||
--hash=sha256:fbce6dae41b692a5973d0f2158f782b9ad05babc2c2019a970a1094a23909b1b
|
||||
fonttools==4.60.1 \
|
||||
--hash=sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c \
|
||||
--hash=sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc \
|
||||
--hash=sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a \
|
||||
--hash=sha256:0eae96373e4b7c9e45d099d7a523444e3554360927225c1cdae221a58a45b856 \
|
||||
--hash=sha256:122e1a8ada290423c493491d002f622b1992b1ab0b488c68e31c413390dc7eb2 \
|
||||
--hash=sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259 \
|
||||
--hash=sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c \
|
||||
--hash=sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce \
|
||||
--hash=sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003 \
|
||||
--hash=sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272 \
|
||||
--hash=sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77 \
|
||||
--hash=sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038 \
|
||||
--hash=sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea \
|
||||
--hash=sha256:2ee06fc57512144d8b0445194c2da9f190f61ad51e230f14836286470c99f854 \
|
||||
--hash=sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2 \
|
||||
--hash=sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258 \
|
||||
--hash=sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652 \
|
||||
--hash=sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08 \
|
||||
--hash=sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99 \
|
||||
--hash=sha256:596ecaca36367027d525b3b426d8a8208169d09edcf8c7506aceb3a38bfb55c7 \
|
||||
--hash=sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914 \
|
||||
--hash=sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6 \
|
||||
--hash=sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed \
|
||||
--hash=sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb \
|
||||
--hash=sha256:7473a8ed9ed09aeaa191301244a5a9dbe46fe0bf54f9d6cd21d83044c3321217 \
|
||||
--hash=sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc \
|
||||
--hash=sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f \
|
||||
--hash=sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c \
|
||||
--hash=sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877 \
|
||||
--hash=sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801 \
|
||||
--hash=sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85 \
|
||||
--hash=sha256:8b4eb332f9501cb1cd3d4d099374a1e1306783ff95489a1026bde9eb02ccc34a \
|
||||
--hash=sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb \
|
||||
--hash=sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383 \
|
||||
--hash=sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401 \
|
||||
--hash=sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28 \
|
||||
--hash=sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01 \
|
||||
--hash=sha256:a140761c4ff63d0cb9256ac752f230460ee225ccef4ad8f68affc723c88e2036 \
|
||||
--hash=sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc \
|
||||
--hash=sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac \
|
||||
--hash=sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903 \
|
||||
--hash=sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3 \
|
||||
--hash=sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6 \
|
||||
--hash=sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c \
|
||||
--hash=sha256:b42d86938e8dda1cd9a1a87a6d82f1818eaf933348429653559a458d027446da \
|
||||
--hash=sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299 \
|
||||
--hash=sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15 \
|
||||
--hash=sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199 \
|
||||
--hash=sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf \
|
||||
--hash=sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1 \
|
||||
--hash=sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537 \
|
||||
--hash=sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d \
|
||||
--hash=sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c \
|
||||
--hash=sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4 \
|
||||
--hash=sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9 \
|
||||
--hash=sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed \
|
||||
--hash=sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987 \
|
||||
--hash=sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa
|
||||
# via weasyprint
|
||||
identify==2.6.13 \
|
||||
--hash=sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b \
|
||||
--hash=sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32
|
||||
identify==2.6.15 \
|
||||
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
|
||||
--hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
|
||||
# via pre-commit
|
||||
nodeenv==1.9.1 \
|
||||
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
|
||||
@@ -338,128 +251,113 @@ pdf2image==1.17.0 \
|
||||
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
|
||||
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pillow==11.3.0 \
|
||||
--hash=sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2 \
|
||||
--hash=sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214 \
|
||||
--hash=sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e \
|
||||
--hash=sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59 \
|
||||
--hash=sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50 \
|
||||
--hash=sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632 \
|
||||
--hash=sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06 \
|
||||
--hash=sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a \
|
||||
--hash=sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51 \
|
||||
--hash=sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced \
|
||||
--hash=sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f \
|
||||
--hash=sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12 \
|
||||
--hash=sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8 \
|
||||
--hash=sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6 \
|
||||
--hash=sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580 \
|
||||
--hash=sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f \
|
||||
--hash=sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac \
|
||||
--hash=sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860 \
|
||||
--hash=sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd \
|
||||
--hash=sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722 \
|
||||
--hash=sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8 \
|
||||
--hash=sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4 \
|
||||
--hash=sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673 \
|
||||
--hash=sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788 \
|
||||
--hash=sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542 \
|
||||
--hash=sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e \
|
||||
--hash=sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd \
|
||||
--hash=sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8 \
|
||||
--hash=sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523 \
|
||||
--hash=sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967 \
|
||||
--hash=sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809 \
|
||||
--hash=sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477 \
|
||||
--hash=sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027 \
|
||||
--hash=sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae \
|
||||
--hash=sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b \
|
||||
--hash=sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c \
|
||||
--hash=sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f \
|
||||
--hash=sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e \
|
||||
--hash=sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b \
|
||||
--hash=sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7 \
|
||||
--hash=sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27 \
|
||||
--hash=sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361 \
|
||||
--hash=sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae \
|
||||
--hash=sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d \
|
||||
--hash=sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc \
|
||||
--hash=sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58 \
|
||||
--hash=sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad \
|
||||
--hash=sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6 \
|
||||
--hash=sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024 \
|
||||
--hash=sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978 \
|
||||
--hash=sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb \
|
||||
--hash=sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d \
|
||||
--hash=sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0 \
|
||||
--hash=sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9 \
|
||||
--hash=sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f \
|
||||
--hash=sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874 \
|
||||
--hash=sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa \
|
||||
--hash=sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081 \
|
||||
--hash=sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149 \
|
||||
--hash=sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6 \
|
||||
--hash=sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d \
|
||||
--hash=sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd \
|
||||
--hash=sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f \
|
||||
--hash=sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c \
|
||||
--hash=sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31 \
|
||||
--hash=sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e \
|
||||
--hash=sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db \
|
||||
--hash=sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6 \
|
||||
--hash=sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f \
|
||||
--hash=sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494 \
|
||||
--hash=sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69 \
|
||||
--hash=sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94 \
|
||||
--hash=sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77 \
|
||||
--hash=sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d \
|
||||
--hash=sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7 \
|
||||
--hash=sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a \
|
||||
--hash=sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438 \
|
||||
--hash=sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288 \
|
||||
--hash=sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b \
|
||||
--hash=sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635 \
|
||||
--hash=sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3 \
|
||||
--hash=sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d \
|
||||
--hash=sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe \
|
||||
--hash=sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0 \
|
||||
--hash=sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe \
|
||||
--hash=sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a \
|
||||
--hash=sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805 \
|
||||
--hash=sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8 \
|
||||
--hash=sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36 \
|
||||
--hash=sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a \
|
||||
--hash=sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b \
|
||||
--hash=sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e \
|
||||
--hash=sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25 \
|
||||
--hash=sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12 \
|
||||
--hash=sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada \
|
||||
--hash=sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c \
|
||||
--hash=sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71 \
|
||||
--hash=sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d \
|
||||
--hash=sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c \
|
||||
--hash=sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6 \
|
||||
--hash=sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1 \
|
||||
--hash=sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50 \
|
||||
--hash=sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653 \
|
||||
--hash=sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c \
|
||||
--hash=sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4 \
|
||||
--hash=sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3
|
||||
pillow==12.0.0 \
|
||||
--hash=sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643 \
|
||||
--hash=sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e \
|
||||
--hash=sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e \
|
||||
--hash=sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc \
|
||||
--hash=sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642 \
|
||||
--hash=sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6 \
|
||||
--hash=sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1 \
|
||||
--hash=sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b \
|
||||
--hash=sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399 \
|
||||
--hash=sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba \
|
||||
--hash=sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad \
|
||||
--hash=sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47 \
|
||||
--hash=sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739 \
|
||||
--hash=sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b \
|
||||
--hash=sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f \
|
||||
--hash=sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10 \
|
||||
--hash=sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52 \
|
||||
--hash=sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d \
|
||||
--hash=sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b \
|
||||
--hash=sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a \
|
||||
--hash=sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9 \
|
||||
--hash=sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d \
|
||||
--hash=sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098 \
|
||||
--hash=sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905 \
|
||||
--hash=sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b \
|
||||
--hash=sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3 \
|
||||
--hash=sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371 \
|
||||
--hash=sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953 \
|
||||
--hash=sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01 \
|
||||
--hash=sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca \
|
||||
--hash=sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e \
|
||||
--hash=sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7 \
|
||||
--hash=sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27 \
|
||||
--hash=sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082 \
|
||||
--hash=sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e \
|
||||
--hash=sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d \
|
||||
--hash=sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8 \
|
||||
--hash=sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a \
|
||||
--hash=sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad \
|
||||
--hash=sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3 \
|
||||
--hash=sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a \
|
||||
--hash=sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d \
|
||||
--hash=sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353 \
|
||||
--hash=sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee \
|
||||
--hash=sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b \
|
||||
--hash=sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b \
|
||||
--hash=sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a \
|
||||
--hash=sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7 \
|
||||
--hash=sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef \
|
||||
--hash=sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a \
|
||||
--hash=sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a \
|
||||
--hash=sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257 \
|
||||
--hash=sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07 \
|
||||
--hash=sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4 \
|
||||
--hash=sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c \
|
||||
--hash=sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c \
|
||||
--hash=sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4 \
|
||||
--hash=sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe \
|
||||
--hash=sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8 \
|
||||
--hash=sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5 \
|
||||
--hash=sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6 \
|
||||
--hash=sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e \
|
||||
--hash=sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8 \
|
||||
--hash=sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e \
|
||||
--hash=sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275 \
|
||||
--hash=sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3 \
|
||||
--hash=sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76 \
|
||||
--hash=sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227 \
|
||||
--hash=sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9 \
|
||||
--hash=sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5 \
|
||||
--hash=sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79 \
|
||||
--hash=sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca \
|
||||
--hash=sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa \
|
||||
--hash=sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b \
|
||||
--hash=sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e \
|
||||
--hash=sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197 \
|
||||
--hash=sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab \
|
||||
--hash=sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79 \
|
||||
--hash=sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2 \
|
||||
--hash=sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363 \
|
||||
--hash=sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0 \
|
||||
--hash=sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e \
|
||||
--hash=sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782 \
|
||||
--hash=sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925 \
|
||||
--hash=sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0 \
|
||||
--hash=sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b \
|
||||
--hash=sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced \
|
||||
--hash=sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c \
|
||||
--hash=sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344 \
|
||||
--hash=sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9 \
|
||||
--hash=sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1
|
||||
# via
|
||||
# -r .github/scripts/requirements_dev.in
|
||||
# pdf2image
|
||||
# weasyprint
|
||||
platformdirs==4.3.8 \
|
||||
--hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \
|
||||
--hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4
|
||||
platformdirs==4.5.0 \
|
||||
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
|
||||
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
|
||||
# via virtualenv
|
||||
pre-commit==4.3.0 \
|
||||
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
|
||||
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
pycparser==2.22 \
|
||||
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
|
||||
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
|
||||
pycparser==2.23 \
|
||||
--hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \
|
||||
--hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
|
||||
# via cffi
|
||||
pydyf==0.11.0 \
|
||||
--hash=sha256:0aaf9e2ebbe786ec7a78ec3fbffa4cdcecde53fd6f563221d53c6bc1328848a3 \
|
||||
@@ -469,60 +367,80 @@ pyphen==0.17.2 \
|
||||
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
|
||||
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
|
||||
# via weasyprint
|
||||
pyyaml==6.0.2 \
|
||||
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
|
||||
--hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \
|
||||
--hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \
|
||||
--hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \
|
||||
--hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \
|
||||
--hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \
|
||||
--hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \
|
||||
--hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \
|
||||
--hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \
|
||||
--hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \
|
||||
--hash=sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a \
|
||||
--hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \
|
||||
--hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \
|
||||
--hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \
|
||||
--hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \
|
||||
--hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \
|
||||
--hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \
|
||||
--hash=sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a \
|
||||
--hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \
|
||||
--hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \
|
||||
--hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \
|
||||
--hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \
|
||||
--hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \
|
||||
--hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \
|
||||
--hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \
|
||||
--hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \
|
||||
--hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \
|
||||
--hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \
|
||||
--hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \
|
||||
--hash=sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706 \
|
||||
--hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \
|
||||
--hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \
|
||||
--hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \
|
||||
--hash=sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083 \
|
||||
--hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \
|
||||
--hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \
|
||||
--hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \
|
||||
--hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \
|
||||
--hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \
|
||||
--hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \
|
||||
--hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \
|
||||
--hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \
|
||||
--hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \
|
||||
--hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \
|
||||
--hash=sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5 \
|
||||
--hash=sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d \
|
||||
--hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \
|
||||
--hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \
|
||||
--hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \
|
||||
--hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \
|
||||
--hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \
|
||||
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
|
||||
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
||||
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
|
||||
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
|
||||
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
|
||||
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
|
||||
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
|
||||
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
|
||||
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
|
||||
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
|
||||
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
|
||||
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
|
||||
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
|
||||
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
|
||||
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
|
||||
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
|
||||
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
|
||||
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
|
||||
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
|
||||
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
|
||||
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
|
||||
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
|
||||
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
|
||||
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
|
||||
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
|
||||
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
|
||||
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
|
||||
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
|
||||
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
|
||||
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
|
||||
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
|
||||
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
|
||||
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
|
||||
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
|
||||
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
|
||||
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
|
||||
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
|
||||
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
|
||||
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
|
||||
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
|
||||
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
|
||||
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
|
||||
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
|
||||
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
|
||||
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
|
||||
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
|
||||
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
|
||||
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
|
||||
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
|
||||
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
|
||||
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
|
||||
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
|
||||
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
|
||||
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
|
||||
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
|
||||
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
|
||||
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
|
||||
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
|
||||
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
|
||||
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
|
||||
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
|
||||
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
|
||||
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
|
||||
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
|
||||
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
|
||||
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
|
||||
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
|
||||
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
tinycss2==1.4.0 \
|
||||
--hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
|
||||
@@ -534,13 +452,13 @@ tinyhtml5==2.0.0 \
|
||||
--hash=sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc \
|
||||
--hash=sha256:13683277c5b176d070f82d099d977194b7a1e26815b016114f581a74bbfbf47e
|
||||
# via weasyprint
|
||||
unoserver==3.3.2 \
|
||||
--hash=sha256:1eeb7467cf6b56b8eff3b576e2d1b2b2ff4e0eb2052e995ac80a1456de300639 \
|
||||
--hash=sha256:87e144f903ee21951b2e06a97549450c13ed7eca5bcebad942d3352d4e882616
|
||||
unoserver==3.4 \
|
||||
--hash=sha256:3dcf2204013def1d1ddd3671f38b11346bdf349fef9728277462666a8a634419 \
|
||||
--hash=sha256:64c24d33d4f65d680a2d9f676518cb28e7fd6c1f9d9a745c33e4a4cb59afdfcd
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
virtualenv==20.33.1 \
|
||||
--hash=sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67 \
|
||||
--hash=sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8
|
||||
virtualenv==20.35.4 \
|
||||
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
|
||||
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
|
||||
# via pre-commit
|
||||
weasyprint==66.0 \
|
||||
--hash=sha256:82b0783b726fcd318e2c977dcdddca76515b30044bc7a830cc4fbe717582a6d0 \
|
||||
@@ -628,9 +546,9 @@ zopfli==0.2.3.post1 \
|
||||
# via fonttools
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
pip==25.2 \
|
||||
--hash=sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2 \
|
||||
--hash=sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717
|
||||
pip==25.3 \
|
||||
--hash=sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343 \
|
||||
--hash=sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd
|
||||
# via -r .github/scripts/requirements_dev.in
|
||||
setuptools==80.9.0 \
|
||||
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
|
||||
|
||||
@@ -12,9 +12,9 @@ distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
filelock==3.19.1 \
|
||||
--hash=sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58 \
|
||||
--hash=sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d
|
||||
filelock==3.20.0 \
|
||||
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
|
||||
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
|
||||
# via virtualenv
|
||||
identify==2.6.15 \
|
||||
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
|
||||
@@ -24,9 +24,9 @@ nodeenv==1.9.1 \
|
||||
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
|
||||
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
|
||||
# via pre-commit
|
||||
platformdirs==4.4.0 \
|
||||
--hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \
|
||||
--hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf
|
||||
platformdirs==4.5.0 \
|
||||
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
|
||||
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
|
||||
# via virtualenv
|
||||
pre-commit==4.3.0 \
|
||||
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
|
||||
@@ -107,7 +107,7 @@ pyyaml==6.0.3 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
virtualenv==20.34.0 \
|
||||
--hash=sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026 \
|
||||
--hash=sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a
|
||||
virtualenv==20.35.4 \
|
||||
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
|
||||
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
|
||||
# via pre-commit
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -52,7 +52,6 @@ jobs:
|
||||
core.setOutput('repository', pr.head.repo.full_name);
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('is_fork', String(pr.head.repo.fork));
|
||||
core.setOutput('base_ref', pr.base.ref);
|
||||
core.setOutput('author', pr.user.login);
|
||||
core.setOutput('state', pr.state);
|
||||
|
||||
@@ -65,10 +64,6 @@ jobs:
|
||||
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
|
||||
# nur bei workflow_dispatch gesetzt:
|
||||
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
|
||||
# für Auto-PR-Logik:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
|
||||
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
|
||||
run: |
|
||||
set -e
|
||||
@@ -89,14 +84,8 @@ jobs:
|
||||
else
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
|
||||
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
|
||||
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
|
||||
if [ "$is_auth" = true ]; then
|
||||
should=true
|
||||
else
|
||||
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
|
||||
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
|
||||
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
|
||||
should=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -139,7 +128,7 @@ jobs:
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -174,7 +163,7 @@ jobs:
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
|
||||
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
|
||||
});
|
||||
return newComment.id;
|
||||
|
||||
@@ -306,8 +295,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"
|
||||
@@ -361,7 +352,7 @@ jobs:
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -392,7 +383,7 @@ jobs:
|
||||
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
@@ -428,7 +419,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
if: |
|
||||
vars.CI_PROFILE != 'lite' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, 'prdeploy') ||
|
||||
@@ -40,12 +41,12 @@ jobs:
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -128,12 +129,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -145,7 +146,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -180,7 +181,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: VERSION_TAG=alpha
|
||||
@@ -356,3 +357,149 @@ jobs:
|
||||
rm -f ../private.key docker-compose.yml
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true
|
||||
|
||||
handle-label-commands:
|
||||
if: ${{ github.event.issue.pull_request != null }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Apply label commands
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { comment, issue } = context.payload;
|
||||
const commentBody = comment?.body ?? '';
|
||||
if (!commentBody.includes('::label::')) {
|
||||
core.info('No label commands detected in comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'config', 'repo_devs.json');
|
||||
const repoDevsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const label_changer = (repoDevsConfig.label_changer || []).map((login) => login.toLowerCase());
|
||||
|
||||
const commenter = (comment?.user?.login || '').toLowerCase();
|
||||
if (!label_changer.includes(commenter)) {
|
||||
core.info(`User ${commenter} is not authorized to manage labels.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const labelsConfigPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'labels.yml');
|
||||
const labelsFile = fs.readFileSync(labelsConfigPath, 'utf8');
|
||||
|
||||
const labelNameMap = new Map();
|
||||
for (const match of labelsFile.matchAll(/-\s+name:\s*(?:"([^"]+)"|'([^']+)'|([^\n]+))/g)) {
|
||||
const labelName = (match[1] ?? match[2] ?? match[3] ?? '').trim();
|
||||
|
||||
if (!labelName) {
|
||||
continue;
|
||||
}
|
||||
const normalized = labelName.toLowerCase();
|
||||
if (!labelNameMap.has(normalized)) {
|
||||
labelNameMap.set(normalized, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!labelNameMap.size) {
|
||||
core.warning('No labels could be read from .github/labels.yml; aborting label commands.');
|
||||
return;
|
||||
}
|
||||
|
||||
let allowedLabelNames = new Set(labelNameMap.values());
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
const commandRegex = /^(\w+)::(label)::"([^"]+)"/gim;
|
||||
let match;
|
||||
while ((match = commandRegex.exec(commentBody)) !== null) {
|
||||
core.info(`Found label command: ${match[0]} (action: ${match[1]}, label: ${match[2]}, labelName: ${match[3]})`);
|
||||
const action = match[1].toLowerCase();
|
||||
const labelName = match[3].trim();
|
||||
|
||||
if (!labelName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = labelName.toLowerCase();
|
||||
const resolvedLabelName = labelNameMap.get(normalized);
|
||||
if (action === 'add') {
|
||||
if (!resolvedLabelName) {
|
||||
core.warning(`Label "${labelName}" is not defined in .github/labels.yml and cannot be added.`);
|
||||
continue;
|
||||
}
|
||||
if (!allowedLabelNames.has(resolvedLabelName)) {
|
||||
core.warning(`Label "${resolvedLabelName}" is not allowed for add commands and will be skipped.`);
|
||||
continue;
|
||||
}
|
||||
labelsToAdd.add(resolvedLabelName);
|
||||
} else if (action === 'rm') {
|
||||
const labelToRemove = resolvedLabelName ?? labelName;
|
||||
if (!resolvedLabelName) {
|
||||
core.warning(`Label "${labelName}" is not defined in .github/labels.yml; attempting to remove as provided.`);
|
||||
}
|
||||
labelsToRemove.add(labelToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
const addLabels = Array.from(labelsToAdd);
|
||||
const removeLabels = Array.from(labelsToRemove);
|
||||
|
||||
if (!addLabels.length && !removeLabels.length) {
|
||||
core.info('No valid label commands found after parsing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const issueParams = {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
};
|
||||
|
||||
if (addLabels.length) {
|
||||
core.info(`Adding labels: ${addLabels.join(', ')}`);
|
||||
await github.rest.issues.addLabels({
|
||||
...issueParams,
|
||||
labels: addLabels,
|
||||
});
|
||||
}
|
||||
|
||||
for (const labelName of removeLabels) {
|
||||
core.info(`Removing label: ${labelName}`);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...issueParams,
|
||||
name: labelName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.warning(`Label "${labelName}" was not present on the pull request.`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
core.info('Processed label commands and deleted the comment.');
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
|
||||
@@ -19,11 +19,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
|
||||
@@ -2,6 +2,9 @@ name: "Auto Pull Request Labeler V2"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches:
|
||||
- main
|
||||
- V2
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,11 +16,11 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
|
||||
@@ -238,7 +238,7 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip' # caching pip dependencies
|
||||
@@ -262,7 +262,13 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
|
||||
include:
|
||||
- docker-rev: docker/embedded/Dockerfile
|
||||
artifact-suffix: Dockerfile
|
||||
- docker-rev: docker/embedded/Dockerfile.ultra-lite
|
||||
artifact-suffix: Dockerfile.ultra-lite
|
||||
- docker-rev: docker/embedded/Dockerfile.fat
|
||||
artifact-suffix: Dockerfile.fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
@@ -272,6 +278,13 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Free disk space on runner
|
||||
run: |
|
||||
echo "Disk space before cleanup:" && df -h
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
@@ -301,7 +314,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/backend/${{ matrix.docker-rev }}
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -313,7 +326,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: reports-docker-${{ matrix.docker-rev }}
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
build/reports/tests/
|
||||
build/test-results/
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
name: Check Properties Files on PR
|
||||
name: Check TOML Translation Files on PR
|
||||
|
||||
# This workflow validates TOML translation files
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "app/core/src/main/resources/messages_*.properties"
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -30,12 +25,12 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main branch first
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -73,22 +68,22 @@ jobs:
|
||||
run: |
|
||||
echo "Fetching PR changed files..."
|
||||
echo "Getting list of changed files from PR..."
|
||||
# Check if PR number exists
|
||||
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
|
||||
echo "Error: PR number is empty"
|
||||
exit 1
|
||||
fi
|
||||
# Get changed files and filter for properties files, handle case where no matches are found
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
|
||||
# Check if any files were found
|
||||
if [ ! -s changed_files.txt ]; then
|
||||
echo "No properties files changed in this PR"
|
||||
echo "Workflow will exit early as no relevant files to check"
|
||||
exit 0
|
||||
fi
|
||||
echo "Found $(wc -l < changed_files.txt) matching properties files"
|
||||
# Check if PR number exists
|
||||
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
|
||||
echo "Error: PR number is empty"
|
||||
exit 1
|
||||
fi
|
||||
# Get changed files and filter for TOML translation files
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
|
||||
# Check if any files were found
|
||||
if [ ! -s changed_files.txt ]; then
|
||||
echo "No TOML translation files changed in this PR"
|
||||
echo "Workflow will exit early as no relevant files to check"
|
||||
exit 0
|
||||
fi
|
||||
echo "Found $(wc -l < changed_files.txt) matching TOML files"
|
||||
|
||||
- name: Determine reference file test
|
||||
- name: Determine reference file
|
||||
id: determine-file
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
@@ -125,11 +120,11 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
// Filter for relevant files based on the PR changes
|
||||
// Filter for relevant TOML files based on the PR changes
|
||||
const changedFiles = files
|
||||
.filter(file =>
|
||||
file.status !== "removed" &&
|
||||
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
|
||||
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
|
||||
)
|
||||
.map(file => file.filename);
|
||||
|
||||
@@ -169,16 +164,16 @@ jobs:
|
||||
|
||||
// Determine reference file
|
||||
let referenceFilePath;
|
||||
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
|
||||
if (changedFiles.includes("frontend/public/locales/en-GB/translation.toml")) {
|
||||
console.log("Using PR branch reference file.");
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: prRepoOwner,
|
||||
repo: prRepoName,
|
||||
path: "app/core/src/main/resources/messages_en_GB.properties",
|
||||
path: "frontend/public/locales/en-GB/translation.toml",
|
||||
ref: branch,
|
||||
});
|
||||
|
||||
referenceFilePath = "pr-branch-messages_en_GB.properties";
|
||||
referenceFilePath = "pr-branch-translation-en-GB.toml";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
} else {
|
||||
@@ -186,11 +181,11 @@ jobs:
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
path: "app/core/src/main/resources/messages_en_GB.properties",
|
||||
path: "frontend/public/locales/en-GB/translation.toml",
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
referenceFilePath = "main-branch-messages_en_GB.properties";
|
||||
referenceFilePath = "main-branch-translation-en-GB.toml";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
}
|
||||
@@ -198,11 +193,20 @@ jobs:
|
||||
console.log(`Reference file path: ${referenceFilePath}`);
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install tomli-w
|
||||
|
||||
- name: Run Python script to check files
|
||||
id: run-check
|
||||
run: |
|
||||
echo "Running Python script to check files..."
|
||||
python .github/scripts/check_language_properties.py \
|
||||
echo "Running Python script to check TOML files..."
|
||||
python .github/scripts/check_language_toml.py \
|
||||
--actor ${{ github.event.pull_request.user.login }} \
|
||||
--reference-file "${REFERENCE_FILE}" \
|
||||
--branch "pr-branch" \
|
||||
@@ -213,7 +217,7 @@ jobs:
|
||||
id: capture-output
|
||||
run: |
|
||||
if [ -f result.txt ] && [ -s result.txt ]; then
|
||||
echo "Test, capturing output..."
|
||||
echo "Capturing output..."
|
||||
SCRIPT_OUTPUT=$(cat result.txt)
|
||||
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
|
||||
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
|
||||
@@ -227,7 +231,7 @@ jobs:
|
||||
echo "FAIL_JOB=false" >> $GITHUB_ENV
|
||||
fi
|
||||
else
|
||||
echo "No update found."
|
||||
echo "No output found."
|
||||
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
|
||||
echo "FAIL_JOB=false" >> $GITHUB_ENV
|
||||
fi
|
||||
@@ -249,7 +253,7 @@ jobs:
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
|
||||
const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary"));
|
||||
|
||||
// Only update or create comments by the action user
|
||||
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
|
||||
@@ -260,7 +264,7 @@ jobs:
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
comment_id: comment.id,
|
||||
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
});
|
||||
console.log("Updated existing comment.");
|
||||
} else if (!comment) {
|
||||
@@ -269,7 +273,7 @@ jobs:
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
});
|
||||
console.log("Created new comment.");
|
||||
} else {
|
||||
@@ -287,6 +291,6 @@ jobs:
|
||||
run: |
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -rf pr-branch
|
||||
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
|
||||
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
config-file: './.github/config/dependency-review-config.yml'
|
||||
|
||||
@@ -253,7 +253,7 @@ jobs:
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update 3rd Party Licenses"
|
||||
|
||||
@@ -15,12 +15,12 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Run Labeler
|
||||
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Test Installers Build
|
||||
name: Multi-OS Tauri Releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,7 +6,23 @@ on:
|
||||
test_mode:
|
||||
description: "Run in test mode (skip release step)"
|
||||
required: false
|
||||
default: "false"
|
||||
default: "true"
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo, V2-master]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
@@ -14,305 +30,547 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
read_versions:
|
||||
determine-matrix:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
# ✅ Get version from Gradle
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
echo "Running gradlew printVersion..."
|
||||
./gradlew printVersion --quiet
|
||||
VERSION=$(./gradlew printVersion --quiet | tail -1)
|
||||
echo "Extracted version: $VERSION"
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# ✅ Get Mac-specific version from Gradle
|
||||
- name: Get version number mac
|
||||
id: versionNumberMac
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
|
||||
echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
|
||||
|
||||
build-portable:
|
||||
needs: read_versions
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
|
||||
run: ./gradlew clean createExe
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Rename binaries
|
||||
run: |
|
||||
mkdir ./binaries
|
||||
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
|
||||
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: stirling${{ matrix.file_suffix }}-binaries
|
||||
path: |
|
||||
./binaries/*
|
||||
|
||||
sign_verify-portable:
|
||||
needs: [build-portable, read_versions]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "with-login-"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: stirling-${{ matrix.file_suffix }}binaries
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: stirling-${{ matrix.file_suffix }}signed
|
||||
path: |
|
||||
./*
|
||||
!cosign.*
|
||||
|
||||
build-installers:
|
||||
needs: read_versions
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win-
|
||||
- os: macos-latest
|
||||
platform: mac-
|
||||
# - os: ubuntu-latest
|
||||
# platform: linux-
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
# Install Windows dependencies
|
||||
- name: Install WiX Toolset
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
curl -L -o wix.exe https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe
|
||||
.\wix.exe /install /quiet
|
||||
|
||||
# Build installer
|
||||
- name: Build Installer
|
||||
run: ./gradlew build jpackage -x test --info
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
BROWSER_OPEN: true
|
||||
|
||||
- name: Set up JDK (x86_64)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
curl -L -o jdk.tar.gz https://cdn.azul.com/zulu/bin/zulu17.56.15-ca-jdk17.0.14-macosx_x64.tar.gz
|
||||
mkdir -p zulu17
|
||||
tar -xzf jdk.tar.gz -C zulu17 --strip-components=1
|
||||
echo "JAVA_HOME=$PWD/zulu17" >> $GITHUB_ENV
|
||||
echo "$PWD/zulu17/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify JDK architecture
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: file $JAVA_HOME/bin/java
|
||||
|
||||
- name: Build project and run jpackage (x86_64)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: arch -x86_64 ./gradlew jpackageMacX64
|
||||
|
||||
# Rename and collect artifacts based on OS
|
||||
- name: Prepare artifacts
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
ls -lah ./build/jpackage/
|
||||
mkdir ./binaries
|
||||
if [ "${{ matrix.os }}" = "windows-latest" ]; then
|
||||
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.version }}.exe" "./binaries/Stirling-PDF-win-installer.exe"
|
||||
elif [ "${{ matrix.os }}" = "macos-latest" ]; then
|
||||
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-installer.dmg"
|
||||
mv "./build/jpackage/x86_64/Stirling PDF (x86_64)-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-x86_64-installer.dmg"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
mv "./build/jpackage/stirling-pdf_${{ needs.read_versions.outputs.version }}-1_amd64.deb" "./binaries/Stirling-PDF-linux-installer.deb"
|
||||
# For push/release events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./binaries
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: ${{ matrix.platform }}binaries
|
||||
path: |
|
||||
./binaries/*
|
||||
|
||||
sign_verify:
|
||||
needs: [read_versions, build-installers]
|
||||
build-jars:
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win-
|
||||
- os: macos-latest
|
||||
platform: mac-
|
||||
# - os: ubuntu-latest
|
||||
# platform: linux-
|
||||
runs-on: ubuntu-latest
|
||||
variant:
|
||||
- name: "default"
|
||||
disable_security: true
|
||||
build_frontend: true
|
||||
file_suffix: ""
|
||||
- name: "with-login"
|
||||
disable_security: false
|
||||
build_frontend: true
|
||||
file_suffix: "-with-login"
|
||||
- name: "server-only"
|
||||
disable_security: true
|
||||
build_frontend: false
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
name: ${{ matrix.platform }}binaries
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Install Cosign
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Generate key pair
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: cosign generate-key-pair
|
||||
- name: Build JAR
|
||||
run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Sign and generate attestations
|
||||
if: matrix.os == 'windows-latest'
|
||||
- name: Rename JAR
|
||||
run: |
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./Stirling-PDF-win-installer.exe.sig \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}"
|
||||
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
|
||||
ls -la app/core/build/libs/
|
||||
mkdir -p ./jar-dist
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./Stirling-PDF-win-installer.exe.sig \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on release or V2-master
|
||||
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: ${{ matrix.platform }}signed
|
||||
path: |
|
||||
./Stirling-PDF-${{ matrix.platform }}installer.*
|
||||
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
|
||||
!cosign.*
|
||||
|
||||
create-release:
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
|
||||
needs: [read_versions, sign_verify, sign_verify-portable]
|
||||
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
|
||||
needs: [determine-matrix, build, build-jars]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
- name: Download all Tauri artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.read_versions.outputs.version }}
|
||||
pattern: Stirling-PDF-*
|
||||
path: ./artifacts/tauri
|
||||
|
||||
- name: Download JAR artifact (default)
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: jar
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (with login)
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: jar-with-login
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (server only)
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: jar-server
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Upload binaries to Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./*signed/*
|
||||
./artifacts/**/*.jar
|
||||
./artifacts/**/*.msi
|
||||
./artifacts/**/*.dmg
|
||||
./artifacts/**/*.deb
|
||||
./artifacts/**/*.AppImage
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip' # caching pip dependencies
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: ":file_folder: pre-commit"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
name: Push Docker Image - V2 Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
- V1_V2_merge
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest (V2-master branch - production)
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V1_V2_merge branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat (V2-master branch - production)
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V1_V2_merge branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-master branch - production)
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V1_V2_merge branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
@@ -24,17 +24,18 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
@@ -54,7 +55,7 @@ jobs:
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
@@ -80,7 +81,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
@@ -88,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Generate tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
@@ -107,7 +108,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -134,7 +135,7 @@ jobs:
|
||||
|
||||
- name: Generate tags ultra-lite
|
||||
id: meta2
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
@@ -152,7 +153,7 @@ jobs:
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.ultra-lite
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -165,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Generate tags fat
|
||||
id: meta3
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -183,7 +184,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile.fat
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -2,48 +2,31 @@ name: Release Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [created]
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
determine-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
|
||||
run: ./gradlew clean createExe
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -51,36 +34,413 @@ jobs:
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Rename binaries
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
mv ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
mv ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Debug build artifacts
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
echo "Current Directory: $(pwd)"
|
||||
ls -R ./build/libs
|
||||
ls -R ./build/launch4j
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: binaries${{ matrix.file_suffix }}
|
||||
path: |
|
||||
./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
|
||||
./build/libs/Stirling-PDF${{ matrix.file_suffix }}.*
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 30
|
||||
|
||||
sign_verify:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
@@ -88,9 +448,10 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: binaries${{ matrix.file_suffix }}
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
@@ -101,80 +462,74 @@ jobs:
|
||||
run: cosign generate-key-pair
|
||||
|
||||
- name: Sign and generate attestations
|
||||
shell: bash
|
||||
run: |
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Sign all artifacts for this platform
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
|
||||
echo "Signing: $file"
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.intoto.jsonl \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Sign the artifact
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Generate attestation
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation "${file}.intoto.jsonl" \
|
||||
"$file"
|
||||
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
# Verify the signature
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.intoto.jsonl \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
echo "✅ Signed and verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
name: Stirling-PDF-${{ matrix.name }}-signed
|
||||
path: |
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.*
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
|
||||
*
|
||||
!cosign.key
|
||||
!cosign.pub
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
needs: [build, sign_verify]
|
||||
needs: [determine-matrix, build, sign_verify]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- name: Download all signed artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
pattern: Stirling-PDF-*-signed
|
||||
path: ./artifacts
|
||||
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./libs/Stirling-PDF*
|
||||
./launch4j/Stirling-PDF-Server*
|
||||
files: ./artifacts/**/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -17,6 +17,7 @@ permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -34,12 +35,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -66,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -74,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
name: Run Sonarqube
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Build and analyze with Gradle
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
run: |
|
||||
./gradlew clean build sonar \
|
||||
-Dsonar.projectKey=Stirling-Tools_Stirling-PDF \
|
||||
-Dsonar.organization=stirling-tools \
|
||||
-Dsonar.host.url=https://sonarcloud.io \
|
||||
-Dsonar.login=${SONAR_TOKEN} \
|
||||
-Dsonar.log.level=DEBUG \
|
||||
--info
|
||||
|
||||
- name: Upload Problems Report on Failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: gradle-problems-report
|
||||
path: build/reports/problems/problems-report.html
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Sonar Logs on Failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: sonar-logs
|
||||
path: |
|
||||
.scannerwork/report-task.txt
|
||||
build/sonar/
|
||||
retention-days: 7
|
||||
@@ -10,18 +10,19 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 30 days stale issues
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -23,14 +23,15 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
name: Sync Files
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
- "app/core/src/main/resources/messages_*.properties"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "scripts/ignore_translation.toml"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-files:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Prevents sdist builds → no tar extraction
|
||||
PIP_ONLY_BINARY: ":all:"
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Sync translation property files
|
||||
run: |
|
||||
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add app/core/src/main/resources/messages_*.properties
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
|
||||
|
||||
- name: Install dependencies
|
||||
# Wheels-only + Hash-Pinning
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation.py
|
||||
|
||||
- name: Run git add
|
||||
run: |
|
||||
git add README.md scripts/ignore_translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync_readme
|
||||
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
|
||||
body: |
|
||||
### Description of Changes
|
||||
|
||||
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
|
||||
|
||||
#### **1. Synchronization of Translation Files**
|
||||
- Updated translation files (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`.
|
||||
- Ensured consistency and synchronization across all supported language files.
|
||||
- Highlighted any missing or incomplete translations.
|
||||
|
||||
#### **2. Update README.md**
|
||||
- Generated the translation progress table in `README.md`.
|
||||
- Added a summary of the current translation status for all supported languages.
|
||||
- Included up-to-date statistics on translation coverage.
|
||||
|
||||
#### **Why these changes are necessary**
|
||||
- Keeps translation files aligned with the latest reference updates.
|
||||
- Ensures the documentation reflects the current translation progress.
|
||||
|
||||
---
|
||||
|
||||
Auto-generated by [create-pull-request][1].
|
||||
|
||||
[1]: https://github.com/peter-evans/create-pull-request
|
||||
draft: false
|
||||
delete-branch: true
|
||||
labels: github-actions
|
||||
sign-commits: true
|
||||
add-paths: |
|
||||
README.md
|
||||
app/core/src/main/resources/messages_*.properties
|
||||
@@ -1,15 +1,15 @@
|
||||
name: Sync Files V2
|
||||
name: Sync Files (TOML)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
- main
|
||||
- syncLangTest
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
- "frontend/public/locales/*/translation.json"
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "scripts/ignore_translation.toml"
|
||||
|
||||
@@ -33,11 +33,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -47,26 +47,30 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Sync translation JSON files
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
|
||||
pip install tomli-w
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add frontend/public/locales/*/translation.json
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
|
||||
git add frontend/public/locales/*/translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install README dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v2.py
|
||||
python scripts/counter_translation_v3.py
|
||||
|
||||
- name: Run git add
|
||||
run: |
|
||||
@@ -75,28 +79,29 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync_readme_v2
|
||||
base: V2
|
||||
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
|
||||
branch: sync_readme_v3
|
||||
base: main
|
||||
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
|
||||
body: |
|
||||
### Description of Changes
|
||||
|
||||
This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made:
|
||||
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
|
||||
|
||||
#### **1. Synchronization of Translation Files**
|
||||
- Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
|
||||
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
|
||||
- Ensured consistency and synchronization across all supported language files.
|
||||
- Highlighted any missing or incomplete translations.
|
||||
- **Format**: TOML
|
||||
|
||||
#### **2. Update README.md**
|
||||
- Generated the translation progress table in `README.md`.
|
||||
- Generated the translation progress table in `README.md` using `counter_translation_v3.py`.
|
||||
- Added a summary of the current translation status for all supported languages.
|
||||
- Included up-to-date statistics on translation coverage.
|
||||
|
||||
@@ -115,4 +120,5 @@ jobs:
|
||||
sign-commits: true
|
||||
add-paths: |
|
||||
README.md
|
||||
frontend/public/locales/*/translation.json
|
||||
frontend/public/locales/*/translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main, V2]
|
||||
branches: [main, V2, V2-tauri-windows]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/desktop/**'
|
||||
@@ -28,6 +28,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
@@ -61,6 +62,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
@@ -89,8 +93,6 @@ jobs:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
@@ -174,6 +176,84 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
@@ -229,13 +309,177 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
SIGN: 1
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# List available certificates and check if they have certificates attached
|
||||
Write-Host "Checking for available certificates..."
|
||||
$certList = & smctl keypair ls 2>&1
|
||||
Write-Host "Keypair list output:"
|
||||
Write-Host $certList
|
||||
Write-Host ""
|
||||
|
||||
# Parse the output to check certificate status
|
||||
$lines = $certList -split "`n"
|
||||
$foundKeypair = $false
|
||||
$hasCertificate = $false
|
||||
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") {
|
||||
$foundKeypair = $true
|
||||
Write-Host "[SUCCESS] Found keypair in list"
|
||||
|
||||
# Check if this line has certificate info (not just empty spaces after alias)
|
||||
$parts = $line -split "\s+"
|
||||
if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") {
|
||||
$hasCertificate = $true
|
||||
Write-Host "[SUCCESS] Certificate is associated with keypair"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $foundKeypair) {
|
||||
Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}"
|
||||
Write-Host "Available keypairs are listed above"
|
||||
Write-Host ""
|
||||
Write-Host "Please verify:"
|
||||
Write-Host " 1. Keypair alias is correct in GitHub secret"
|
||||
Write-Host " 2. API key has access to this keypair"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $hasCertificate) {
|
||||
Write-Host "[ERROR] No certificate associated with keypair"
|
||||
Write-Host "This usually means:"
|
||||
Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)"
|
||||
Write-Host " 2. Certificate is pending approval"
|
||||
Write-Host " 3. Certificate needs to be attached to the keypair"
|
||||
Write-Host ""
|
||||
Write-Host "Try running in DigiCert ONE portal:"
|
||||
Write-Host " smctl keypair sync"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[SUCCESS] Certificate check passed"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
Write-Host ""
|
||||
Write-Host "Possible issues:"
|
||||
Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)"
|
||||
Write-Host " 2. Incorrect keypair alias"
|
||||
Write-Host " 3. API key lacks signing permissions"
|
||||
Write-Host ""
|
||||
Write-Host "Please verify in DigiCert ONE portal:"
|
||||
Write-Host " - Certificate status is 'Issued' (not Pending)"
|
||||
Write-Host " - Keypair status is 'Online'"
|
||||
Write-Host " - 'Can sign' is set to 'Yes'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
@@ -269,6 +513,66 @@ jobs:
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Verify Windows Code Signature
|
||||
if: matrix.platform == 'windows-latest' && github.ref == 'refs/heads/main'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Verifying Windows code signatures..."
|
||||
|
||||
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe"
|
||||
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
|
||||
|
||||
$allSigned = $true
|
||||
$usingKeyLocker = "${{ env.SM_API_KEY }}" -ne ""
|
||||
$usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne ""
|
||||
|
||||
# Check EXE signature
|
||||
if (Test-Path $exePath) {
|
||||
$exeSig = Get-AuthenticodeSignature -FilePath $exePath
|
||||
Write-Host "EXE Signature Status: $($exeSig.Status)"
|
||||
Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)"
|
||||
Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)"
|
||||
|
||||
if ($exeSig.Status -ne "Valid") {
|
||||
Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))"
|
||||
if ($usingKeyLocker -or $usingPfx) {
|
||||
Write-Host "[ERROR] Certificate was provided but signing failed"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SUCCESS] EXE is properly signed"
|
||||
}
|
||||
}
|
||||
|
||||
# Check MSI signature
|
||||
if (Test-Path $msiPath) {
|
||||
$msiSig = Get-AuthenticodeSignature -FilePath $msiPath
|
||||
Write-Host "MSI Signature Status: $($msiSig.Status)"
|
||||
Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)"
|
||||
Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)"
|
||||
|
||||
if ($msiSig.Status -ne "Valid") {
|
||||
Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))"
|
||||
if ($usingKeyLocker -or $usingPfx) {
|
||||
Write-Host "[ERROR] Certificate was provided but signing failed"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SUCCESS] MSI is properly signed"
|
||||
}
|
||||
}
|
||||
|
||||
if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) {
|
||||
Write-Host "[ERROR] Code signing verification failed"
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "[SUCCESS] Code signature verification completed"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
@@ -333,6 +637,8 @@ jobs:
|
||||
if [ "${{ needs.build.result }}" = "success" ]; then
|
||||
echo "✅ All Tauri builds completed successfully!"
|
||||
echo "Artifacts are ready for distribution."
|
||||
elif [ "${{ needs.build.result }}" = "skipped" ]; then
|
||||
echo "⏭️ Tauri builds skipped (CI lite mode enabled)"
|
||||
else
|
||||
echo "❌ Some Tauri builds failed."
|
||||
echo "Please check the logs and fix any issues."
|
||||
|
||||
@@ -21,15 +21,16 @@ permissions:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
@@ -66,7 +67,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
@@ -124,7 +125,7 @@ jobs:
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
@@ -139,14 +140,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
@@ -175,7 +176,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ local.properties
|
||||
version.properties
|
||||
|
||||
#### Stirling-PDF Files ###
|
||||
pipeline/watchedFolders/
|
||||
pipeline/finishedFolders/
|
||||
pipeline/
|
||||
!pipeline/.gitkeep
|
||||
customFiles/
|
||||
configs/
|
||||
watchedFolders/
|
||||
@@ -31,8 +31,28 @@ exampleYmlFiles/stirling/
|
||||
/testing/file_snapshots
|
||||
SwaggerDoc.json
|
||||
|
||||
# Docker bind-mount volumes (local data)
|
||||
AI-Document-Generator-main/backend/data/
|
||||
AI-Document-Generator-main/backend/output/
|
||||
docker/compose/stirling/
|
||||
|
||||
# Frontend build artifacts copied to backend static resources
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
app/core/src/main/resources/static/modern-logo/
|
||||
app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
# Gradle
|
||||
.gradle
|
||||
.gradle-home
|
||||
.lock
|
||||
|
||||
# External tool builders
|
||||
@@ -213,3 +233,16 @@ 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/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.7
|
||||
rev: v0.14.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
@@ -26,7 +26,7 @@ repos:
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
|
||||
@@ -202,10 +202,10 @@ const [ToolName] = (props: BaseToolProps) => {
|
||||
## 5. Add Translations
|
||||
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
|
||||
|
||||
**File to update:** `frontend/public/locales/en-GB/translation.json`
|
||||
**File to update:** `frontend/public/locales/en-GB/translation.toml`
|
||||
|
||||
**Required Translation Keys**:
|
||||
```json
|
||||
```toml
|
||||
{
|
||||
"home": {
|
||||
"[toolName]": {
|
||||
@@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
|
||||
```
|
||||
|
||||
**Translation Notes:**
|
||||
- **Only update `en-GB/translation.json`** - other locale files are managed separately
|
||||
- **Only update `en-GB/translation.toml`** - other locale files are managed separately
|
||||
- Use descriptive keys that match your component's `t()` calls
|
||||
- Include tooltip translations if you created tooltip hooks
|
||||
- Add `options.*` keys if your tool has settings with descriptions
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# LaTeX outputs
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.toc
|
||||
*.pdf
|
||||
*.tex
|
||||
backend/output/
|
||||
backend/data/user_styles.json
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install full TeXLive so LLM outputs (siunitx, paracol, tikz, etc.) compile reliably.
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
texlive-full \
|
||||
latexmk \
|
||||
ghostscript \
|
||||
poppler-utils \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy backend files
|
||||
COPY backend/requirements.txt .
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
|
||||
# Create output directories
|
||||
RUN mkdir -p /app/output /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Run the Flask app
|
||||
CMD ["python", "app.py"]
|
||||
@@ -0,0 +1,173 @@
|
||||
# LaTeX PDF Generator
|
||||
|
||||
AI-powered document generator using LaTeX. Creates professional documents (invoices, resumes, contracts, etc.) from natural language prompts with conversational editing.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎨 ChatGPT-like interface with split-screen PDF preview (main window only)
|
||||
- 📝 Generates LaTeX documents from natural language and compiles to PDF in Docker
|
||||
- 🔄 Conversational editing with PDF regeneration on every turn
|
||||
- 💾 Style memory + template reuse per user/team and document type
|
||||
- 🗂️ Version history with per-iteration PDFs you can reopen
|
||||
- 📄 Supports: Invoices, Resumes, Contracts, Letters, Reports, poems, proposals, and more
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# Build the Docker image
|
||||
docker build -t latex-generator .
|
||||
|
||||
# Run the backend
|
||||
docker run -p 5000:5000 latex-generator
|
||||
```
|
||||
|
||||
### Option 2: Local Development
|
||||
|
||||
#### Backend Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install LaTeX (if not already installed)
|
||||
# On Ubuntu/Debian:
|
||||
sudo apt-get install texlive-latex-base texlive-latex-extra
|
||||
|
||||
# On Mac:
|
||||
brew install --cask mactex
|
||||
|
||||
# On Windows:
|
||||
# Download and install MiKTeX from https://miktex.org/download
|
||||
|
||||
# Run the backend
|
||||
python app.py
|
||||
```
|
||||
|
||||
Backend will run on `http://localhost:5000`
|
||||
|
||||
#### Frontend Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run the dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend will run on `http://localhost:3000`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open `http://localhost:3000` in your browser
|
||||
2. Type a prompt like "Create an invoice for web development services"
|
||||
3. The AI generates LaTeX code and compiles it to PDF
|
||||
4. Continue chatting to refine the document
|
||||
5. Download the final PDF
|
||||
|
||||
## Example Prompts
|
||||
|
||||
- "Create a professional invoice for $1,500 in consulting services"
|
||||
- "Generate a resume for a senior software engineer with 5 years experience"
|
||||
- "Make a business letter to a client about project completion"
|
||||
- "Create a contract for freelance web development"
|
||||
|
||||
## Configuration
|
||||
|
||||
### OpenAI API (Optional)
|
||||
|
||||
To use real AI generation instead of mock templates:
|
||||
|
||||
1. Copy `.env.example` to `.env`
|
||||
2. Add your OpenAI API key: `OPENAI_API_KEY=sk-...`
|
||||
3. (Optional) Choose models:
|
||||
- Smart (full generation): `SMART_MODEL=gpt-5.1` (default)
|
||||
- Fast (intent/pre checks): `FAST_MODEL=gpt-4.1-nano` (default)
|
||||
3. (Legacy SDK) We pin `openai==0.28.1` in Docker to avoid client init issues. No code changes needed.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
latex-pdf-generator/
|
||||
├── frontend/ # React + TypeScript + Tailwind
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── landing/ # Landing hero + CTA
|
||||
│ │ │ ├── modals/ # Reusable modal(s)
|
||||
│ │ │ ├── workspace/ # Chat panel, preview, history
|
||||
│ │ │ └── ui/ # Buttons, button groups, etc.
|
||||
│ │ ├── hooks/ # Workflow + speech capture hooks
|
||||
│ │ ├── types/ # Shared TypeScript interfaces
|
||||
│ │ ├── App.tsx # Thin orchestrator
|
||||
│ │ └── main.tsx
|
||||
│ ├── .eslintrc.cjs # ESLint config (React + TS)
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── backend/ # Flask + LaTeX
|
||||
│ ├── app.py # Routes + Flask app factory
|
||||
│ ├── ai_generation.py # OpenAI + mock generation helpers
|
||||
│ ├── briefs.py # Guided brief collection utilities
|
||||
│ ├── config.py # Logging + environment setup
|
||||
│ ├── document_types.py # Doc-type heuristics
|
||||
│ ├── latex_utils.py # LaTeX sanitizers + layout helpers
|
||||
│ ├── pdf_utils.py # PDF compilation + render helpers
|
||||
│ ├── storage.py # JSON persistence for users/templates
|
||||
│ ├── styles.py # Style preference heuristics
|
||||
│ ├── vision.py # Layout extraction via multimodal GPT
|
||||
│ ├── requirements.txt
|
||||
│ └── data/ # User style storage
|
||||
├── Dockerfile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
The frontend now ships with ESLint + TypeScript rules that keep the new modular structure tidy:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Backend linting can be added with your preferred tool (e.g., ruff or flake8) by pointing it at the new small modules in `backend/`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### LaTeX compilation fails
|
||||
|
||||
- Ensure `pdflatex` is in your PATH
|
||||
- Check logs in the backend console
|
||||
- Verify LaTeX packages are installed
|
||||
|
||||
### CORS errors
|
||||
|
||||
- Make sure both frontend and backend are running
|
||||
- Frontend proxy is configured in `vite.config.ts`
|
||||
|
||||
### PDF not displaying
|
||||
|
||||
- Check browser console for errors
|
||||
- Ensure the backend `/output` endpoint is accessible
|
||||
- Try opening the PDF URL directly
|
||||
|
||||
## Development
|
||||
|
||||
### Mock Mode (Current)
|
||||
|
||||
The app currently uses mock LaTeX templates for quick testing. To enable real AI:
|
||||
|
||||
1. Get an OpenAI API key
|
||||
2. Set `OPENAI_API_KEY` and optionally override:
|
||||
- `SMART_MODEL` (default `gpt-5.1`)
|
||||
- `FAST_MODEL` (default `gpt-4.1-nano`)
|
||||
3. Restart the backend; it auto-detects whether to call the live model or the bundled mock templates
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,659 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
import time
|
||||
|
||||
from config import CLIENT_MODE, SMART_MODEL, STREAMING_ENABLED, get_chat_model, logger
|
||||
from langchain_utils import to_lc_messages
|
||||
from storage import save_user_style
|
||||
from prompts import latex_system_prompt, latex_context_messages
|
||||
|
||||
|
||||
def generate_outline_with_llm(
|
||||
prompt: str,
|
||||
document_type: str,
|
||||
constraints: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
if CLIENT_MODE == "langchain":
|
||||
constraint_text = ""
|
||||
if constraints:
|
||||
tone = constraints.get("tone")
|
||||
audience = constraints.get("audience")
|
||||
pages = constraints.get("pageCount")
|
||||
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
system_prompt = (
|
||||
"You are an outline generator for document creation.\n"
|
||||
f"Document type: {document_type}\n"
|
||||
f"{constraint_text}\n"
|
||||
"Return a concise outline with section titles and short descriptions.\n"
|
||||
"Keep each description to roughly 6-12 words.\n"
|
||||
"Ensure the outline scope fits the target page count.\n"
|
||||
"Output plain text only, using a numbered list with 5-9 sections."
|
||||
)
|
||||
messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
try:
|
||||
llm = get_chat_model(SMART_MODEL)
|
||||
if llm:
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(to_lc_messages(messages))
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[AI] outline model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
SMART_MODEL,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
if content:
|
||||
return str(content).strip()
|
||||
except Exception as exc:
|
||||
logger.error("[AI] Outline generation failed, falling back: %s", exc)
|
||||
|
||||
safe_prompt = prompt.strip() or "Document"
|
||||
return (
|
||||
"1) Introduction - Summary of the document goals.\n"
|
||||
"2) Background - Context and key assumptions.\n"
|
||||
"3) Main Content - Core points and supporting details.\n"
|
||||
"4) Evidence - Data, examples, or references.\n"
|
||||
"5) Conclusion - Wrap-up and next steps.\n"
|
||||
f"Notes: Tailor details to '{safe_prompt}'."
|
||||
)
|
||||
|
||||
|
||||
def _parse_outline_to_sections(outline_text: str) -> List[Dict[str, str]]:
|
||||
lines = [
|
||||
line.strip()
|
||||
for line in outline_text.split("\n")
|
||||
if line.strip() and not re.match(r"^(section|details)$", line.strip(), re.IGNORECASE)
|
||||
]
|
||||
sections: List[Dict[str, str]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
cleaned = re.sub(r"^\d+[\).\s-]+", "", lines[i]).strip()
|
||||
if not cleaned:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
split = re.split(r"[-–:]+", cleaned, maxsplit=1)
|
||||
if len(split) > 1:
|
||||
sections.append({"label": split[0].strip() or "Section", "value": split[1].strip()})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
next_line = lines[i + 1].strip() if i + 1 < len(lines) else ""
|
||||
if next_line and not re.match(r"^\d+[\).\s-]+", next_line):
|
||||
sections.append({"label": cleaned, "value": next_line})
|
||||
i += 2
|
||||
continue
|
||||
|
||||
sections.append({"label": cleaned, "value": ""})
|
||||
i += 1
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _extract_fields_from_prompt(prompt: str, fields: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
||||
lines = [line.strip() for line in prompt.split("\n") if line.strip()]
|
||||
kv_pairs: Dict[str, str] = {}
|
||||
for line in lines:
|
||||
match = re.match(r"^([^:]{2,40}):\s*(.+)$", line)
|
||||
if match:
|
||||
kv_pairs[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
|
||||
email_match = re.search(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", prompt, re.IGNORECASE)
|
||||
phone_match = re.search(r"(\+?\d[\d\s().-]{7,})", prompt)
|
||||
date_match = re.search(r"\b\d{1,2}[\/.-]\d{1,2}[\/.-]\d{2,4}\b", prompt)
|
||||
money_match = re.search(r"\$\s?\d[\d,]*(?:\.\d{2})?", prompt)
|
||||
|
||||
filled: List[Dict[str, str]] = []
|
||||
for field in fields:
|
||||
label = str(field.get("label", "Field"))
|
||||
value = str(field.get("value", "") or "")
|
||||
if value.strip():
|
||||
filled.append({"label": label, "value": value})
|
||||
continue
|
||||
label_lower = label.lower()
|
||||
for key, val in kv_pairs.items():
|
||||
if key in label_lower:
|
||||
value = val
|
||||
break
|
||||
if not value and email_match and "email" in label_lower:
|
||||
value = email_match.group(0)
|
||||
if not value and phone_match and "phone" in label_lower:
|
||||
value = phone_match.group(0)
|
||||
if not value and date_match and ("date" in label_lower or "due" in label_lower):
|
||||
value = date_match.group(0)
|
||||
if not value and money_match and ("total" in label_lower or "amount" in label_lower):
|
||||
value = money_match.group(0)
|
||||
filled.append({"label": label, "value": value})
|
||||
return filled
|
||||
|
||||
|
||||
def generate_field_values(
|
||||
prompt: str,
|
||||
document_type: str,
|
||||
fields: List[Dict[str, Any]],
|
||||
constraints: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
if CLIENT_MODE == "langchain":
|
||||
constraint_text = ""
|
||||
if constraints:
|
||||
tone = constraints.get("tone")
|
||||
audience = constraints.get("audience")
|
||||
pages = constraints.get("pageCount")
|
||||
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
system_prompt = (
|
||||
"You are extracting field values from a user prompt.\n"
|
||||
"Return a JSON array of objects with keys: label, value.\n"
|
||||
"Only fill values that are explicitly stated or strongly implied.\n"
|
||||
"If unknown, return an empty string.\n"
|
||||
f"{constraint_text}\n"
|
||||
"Output JSON only."
|
||||
)
|
||||
messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"Document type: {document_type}"},
|
||||
{"role": "user", "content": f"Prompt:\n{prompt}"},
|
||||
{"role": "user", "content": f"Fields:\n{json.dumps(fields, ensure_ascii=True)}"},
|
||||
]
|
||||
try:
|
||||
llm = get_chat_model(SMART_MODEL)
|
||||
if llm:
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(to_lc_messages(messages))
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[AI] field-extract model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
SMART_MODEL,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
if content:
|
||||
parsed = _extract_json_array(str(content))
|
||||
if parsed:
|
||||
return [
|
||||
{
|
||||
"label": str(item.get("label", "Field")),
|
||||
"value": str(item.get("value", "")),
|
||||
}
|
||||
for item in parsed
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.error("[AI] Field extraction failed, falling back: %s", exc)
|
||||
|
||||
return _extract_fields_from_prompt(prompt, fields)
|
||||
|
||||
def _extract_json_array(payload: str) -> Optional[List[Dict[str, Any]]]:
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
match = re.search(r"\[[\s\S]*\]", payload)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def generate_section_draft(
|
||||
prompt: str,
|
||||
document_type: str,
|
||||
outline_text: str,
|
||||
constraints: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
if CLIENT_MODE == "langchain":
|
||||
constraint_text = ""
|
||||
if constraints:
|
||||
tone = constraints.get("tone")
|
||||
audience = constraints.get("audience")
|
||||
pages = constraints.get("pageCount")
|
||||
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
system_prompt = (
|
||||
"You are generating section content for a document.\n"
|
||||
"Return a JSON array of objects with keys: label, value.\n"
|
||||
"Use the provided outline sections as labels; values should be polished draft text.\n"
|
||||
f"{constraint_text}\n"
|
||||
"Keep the total length appropriate to the target pages.\n"
|
||||
"Output JSON only."
|
||||
)
|
||||
messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"Document type: {document_type}"},
|
||||
{"role": "user", "content": f"Outline:\n{outline_text}"},
|
||||
{"role": "user", "content": f"Prompt:\n{prompt}"},
|
||||
]
|
||||
try:
|
||||
llm = get_chat_model(SMART_MODEL)
|
||||
if llm:
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(to_lc_messages(messages))
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[AI] section-draft model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
SMART_MODEL,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
if content:
|
||||
parsed = _extract_json_array(str(content))
|
||||
if parsed:
|
||||
return [
|
||||
{
|
||||
"label": str(item.get("label", "Section")),
|
||||
"value": str(item.get("value", "")),
|
||||
}
|
||||
for item in parsed
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.error("[AI] Section draft generation failed, falling back: %s", exc)
|
||||
|
||||
if outline_text.strip():
|
||||
return _parse_outline_to_sections(outline_text)
|
||||
|
||||
fallback_label = "Main Content"
|
||||
return [{"label": fallback_label, "value": prompt.strip() or "Draft content"}]
|
||||
|
||||
|
||||
def _fallback_template_fill(template_latex: str, outline_text: str, draft_text: Optional[str] = None) -> str:
|
||||
default_text = draft_text or outline_text or "Details pending."
|
||||
replacements = {
|
||||
"TITLE": "Project Overview",
|
||||
"SUBTITLE": "Executive Summary",
|
||||
"AUTHOR": "Jane Doe",
|
||||
"AUTHOR_LIST": "Jane Doe, John Smith",
|
||||
"AFFILIATIONS": "John Smith Consulting",
|
||||
"ABSTRACT": default_text,
|
||||
"KEYWORDS": "keyword1, keyword2, keyword3",
|
||||
"INTRODUCTION": default_text,
|
||||
"RELATED_WORK": default_text,
|
||||
"METHODOLOGY": default_text,
|
||||
"RESULTS": default_text,
|
||||
"DISCUSSION": default_text,
|
||||
"CONCLUSION": default_text,
|
||||
"REFERENCES": default_text,
|
||||
"MAIN_TEXT": default_text,
|
||||
"FIGURES_TABLES": default_text,
|
||||
"REPORT_TITLE": "Business Report",
|
||||
"DATE": "2025-01-01",
|
||||
"EXEC_SUMMARY": default_text,
|
||||
"BACKGROUND": default_text,
|
||||
"FINDINGS": default_text,
|
||||
"RECOMMENDATIONS": default_text,
|
||||
"APPENDIX": default_text,
|
||||
"NEWSLETTER_TITLE": "Doe Consulting Monthly",
|
||||
"TOP_STORY": default_text,
|
||||
"UPDATES": default_text,
|
||||
"SPOTLIGHT": default_text,
|
||||
"FOOTER": "Contact: info@example.com",
|
||||
"RECIPE_TITLE": "Recipe Title",
|
||||
"SERVINGS": "Serves 4",
|
||||
"TIME": "30 minutes",
|
||||
"INGREDIENTS": "\\\\begin{itemize}\\\\item Ingredient A\\\\item Ingredient B\\\\end{itemize}",
|
||||
"INSTRUCTIONS": default_text,
|
||||
"NOTES": "Notes and tips.",
|
||||
"BUSINESS_NAME": "John Smith Consulting",
|
||||
"BUSINESS_ADDRESS": "123 Example Street, Example City",
|
||||
"BUSINESS_CONTACT": "billing@example.com | (555) 000-0000",
|
||||
"INVOICE_NUMBER": "INV-1001",
|
||||
"ISSUE_DATE": "2025-01-01",
|
||||
"DUE_DATE": "2025-01-15",
|
||||
"CLIENT_NAME": "Doe Corporation",
|
||||
"CLIENT_ADDRESS": "456 Sample Avenue, Example City",
|
||||
"CLIENT_CONTACT": "ap@example.com",
|
||||
"LINE_ITEMS": "Service & 1 & $1000 & $1000 \\\\\\\\",
|
||||
"SUBTOTAL": "$1000",
|
||||
"TAXES": "$0",
|
||||
"TOTAL": "$1000",
|
||||
"PAYMENT_TERMS": "Net 15",
|
||||
"PAYMENT_METHODS": "Bank transfer, credit card",
|
||||
"STUDENT_NAME": "Jane Doe",
|
||||
"COURSE_NAME": "Business Communications",
|
||||
"INSTRUCTOR_NAME": "Dr. Rivera",
|
||||
"ASSIGNMENT_TITLE": "Market Analysis",
|
||||
"PROMPT": default_text,
|
||||
"RESPONSE": default_text,
|
||||
"CHAPTER_ONE_TITLE": "Chapter One",
|
||||
"CHAPTER_ONE": default_text,
|
||||
"CHAPTER_TWO_TITLE": "Chapter Two",
|
||||
"CHAPTER_TWO": default_text,
|
||||
"PREFACE": default_text,
|
||||
"PUBLISHER": "Doe Press",
|
||||
"NAME": "Jane Doe",
|
||||
"TITLE_PAGE": "Project Overview",
|
||||
"EMAIL": "jane.doe@example.com",
|
||||
"PHONE": "(555) 000-0000",
|
||||
"LOCATION": "Example City, USA",
|
||||
"SUMMARY": default_text,
|
||||
"EXPERIENCE": default_text,
|
||||
"EDUCATION": default_text,
|
||||
"SKILLS": default_text,
|
||||
"PROJECTS": default_text,
|
||||
"SUBJECT": "Subject",
|
||||
"BODY": default_text,
|
||||
"RECIPIENT_NAME": "John Smith",
|
||||
"RECIPIENT_TITLE": "Hiring Manager",
|
||||
"RECIPIENT_COMPANY": "Doe Corporation",
|
||||
"RECIPIENT_ADDRESS": "456 Sample Avenue, Example City",
|
||||
"SENDER_NAME": "Jane Doe",
|
||||
"SENDER_ADDRESS": "123 Example Street, Example City",
|
||||
"SENDER_EMAIL": "jane.doe@example.com",
|
||||
"MONTH_YEAR": "January 2025",
|
||||
"THEME": "Theme",
|
||||
"WEEK_ROWS": "1 & 2 & 3 & 4 & 5 & 6 & 7 \\\\\\\\ \\\\hline",
|
||||
"HEADLINE": "Launch Announcement",
|
||||
"SUBTEXT": "Introducing our latest release.",
|
||||
"CALL_TO_ACTION": "Visit example.com to learn more.",
|
||||
"CONTACT": "contact@example.com",
|
||||
"EXPERIMENT_TITLE": "Experiment",
|
||||
"OBJECTIVE": default_text,
|
||||
"MATERIALS": default_text,
|
||||
"PROCEDURE": default_text,
|
||||
"OBSERVATIONS": default_text,
|
||||
"INSTITUTION": "Doe Institute",
|
||||
"PRESENTER": "Jane Doe",
|
||||
"AGENDA": default_text,
|
||||
"KEY_POINTS": default_text,
|
||||
"DATA_VISUALS": default_text,
|
||||
}
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1).strip()
|
||||
return replacements.get(key, default_text)
|
||||
|
||||
return re.sub(r"<<([A-Z0-9_]+)>>", replace, template_latex)
|
||||
|
||||
|
||||
def generate_template_fill_stream(
|
||||
template_latex: str,
|
||||
document_type: str,
|
||||
outline_text: str,
|
||||
draft_sections: Optional[List[Dict[str, str]]] = None,
|
||||
constraints: Optional[Dict[str, Any]] = None,
|
||||
style_profile: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Fill a LaTeX template by replacing placeholders."""
|
||||
if CLIENT_MODE == "langchain":
|
||||
constraints_text = ""
|
||||
if constraints:
|
||||
tone = constraints.get("tone")
|
||||
audience = constraints.get("audience")
|
||||
pages = constraints.get("pageCount")
|
||||
constraints_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
style_text = ""
|
||||
if style_profile:
|
||||
font = style_profile.get("font_preference")
|
||||
layout = style_profile.get("layout_preference")
|
||||
accent = style_profile.get("color_accent")
|
||||
style_text = f"Style preferences: font={font}, layout={layout}, accent={accent}."
|
||||
if draft_sections:
|
||||
constraints_text = f"{constraints_text}\nUse the section content to inform placeholder values."
|
||||
system_prompt = (
|
||||
"You are a LaTeX template filler.\n"
|
||||
"Return the full LaTeX document with placeholders filled.\n"
|
||||
"Rules:\n"
|
||||
"1) Only replace placeholders like <<PLACEHOLDER>>.\n"
|
||||
"2) Do not change any other LaTeX layout/commands.\n"
|
||||
"3) Output ONLY LaTeX (no markdown).\n"
|
||||
"4) If you add color, use the accent token name 'accent'.\n"
|
||||
f"{constraints_text}\n"
|
||||
f"{style_text}\n"
|
||||
"Keep the final output within the target page count.\n"
|
||||
)
|
||||
messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"Document type: {document_type}"},
|
||||
{"role": "user", "content": f"Outline/context:\n{outline_text}"},
|
||||
{"role": "user", "content": f"Template:\n{template_latex}"},
|
||||
]
|
||||
if draft_sections:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Section content (JSON):\n{json.dumps(draft_sections, ensure_ascii=True)}",
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
llm = get_chat_model(SMART_MODEL, streaming=True)
|
||||
if llm:
|
||||
start = time.perf_counter()
|
||||
total_chars = 0
|
||||
chunk_count = 0
|
||||
first_chunk = None
|
||||
for chunk in llm.stream(to_lc_messages(messages)):
|
||||
if chunk.content:
|
||||
if first_chunk is None:
|
||||
first_chunk = time.perf_counter()
|
||||
chunk_count += 1
|
||||
total_chars += len(str(chunk.content))
|
||||
yield chunk.content
|
||||
elapsed = time.perf_counter() - start
|
||||
logger.info(
|
||||
"[AI] template-fill-stream model=%s elapsed=%.2fs first_chunk=%.2fs chunks=%s chars=%s",
|
||||
SMART_MODEL,
|
||||
elapsed,
|
||||
(first_chunk - start) if first_chunk else -1.0,
|
||||
chunk_count,
|
||||
total_chars,
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error("[AI] Template fill failed, falling back: %s", exc)
|
||||
|
||||
draft_text = None
|
||||
if draft_sections:
|
||||
draft_text = "\n".join(
|
||||
f"{section.get('label', 'Section')}: {section.get('value', '')}"
|
||||
for section in draft_sections
|
||||
)
|
||||
filled = _fallback_template_fill(template_latex, outline_text, draft_text)
|
||||
chunk_size = 200
|
||||
for i in range(0, len(filled), chunk_size):
|
||||
yield filled[i : i + chunk_size]
|
||||
|
||||
|
||||
def generate_latex_with_llm(
|
||||
prompt: str,
|
||||
history: List[Dict[str, str]],
|
||||
style_profile: Dict[str, Any],
|
||||
document_type: str,
|
||||
template_hint: Optional[str] = None,
|
||||
current_latex: Optional[str] = None,
|
||||
structured_brief: Optional[str] = None,
|
||||
edit_mode: bool = False,
|
||||
) -> str:
|
||||
"""Call the LLM (when available) or fall back to deterministic templates."""
|
||||
if CLIENT_MODE == "langchain":
|
||||
messages: List[Dict[str, Any]] = [{"role": "system", "content": latex_system_prompt(style_profile, document_type, template_hint)}]
|
||||
messages.extend(history)
|
||||
messages.extend(latex_context_messages(template_hint, current_latex, structured_brief))
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
"[AI] Using live model=%s doc_type=%s template_hint=%s",
|
||||
SMART_MODEL,
|
||||
document_type,
|
||||
"yes" if template_hint else "no",
|
||||
)
|
||||
llm = get_chat_model(SMART_MODEL)
|
||||
if llm:
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(to_lc_messages(messages))
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[AI] latex-generate model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
SMART_MODEL,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
return content
|
||||
except Exception as exc:
|
||||
logger.error("[AI] LangChain generation failed, falling back to mock: %s", exc)
|
||||
|
||||
lower_prompt = prompt.lower()
|
||||
|
||||
if "invoice" in lower_prompt:
|
||||
save_user_style("default_user", {"last_doc_type": "invoice"})
|
||||
details = structured_brief or prompt or "Invoice details provided by user."
|
||||
return r"""\documentclass{article}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{geometry}
|
||||
\geometry{a4paper, margin=1in}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\begin{center}
|
||||
{\LARGE \textbf{INVOICE}}\\[0.5cm]
|
||||
\#1023\\
|
||||
\today
|
||||
\end{center}
|
||||
|
||||
\section*{Bill To:}
|
||||
Client Name \\
|
||||
123 Business Rd.
|
||||
|
||||
\section*{Items}
|
||||
\begin{tabular}{lr}
|
||||
\textbf{Service} & \textbf{Amount} \\
|
||||
\hline
|
||||
% Replace with your line items
|
||||
Description & \$0.00 \\
|
||||
Description & \$0.00 \\
|
||||
\hline
|
||||
\textbf{Total Due} & \textbf{\$0.00} \\
|
||||
\end{tabular}
|
||||
|
||||
\section*{Notes}
|
||||
""" + details + r"""
|
||||
|
||||
\end{document}"""
|
||||
|
||||
if "resume" in lower_prompt or "cv" in lower_prompt:
|
||||
save_user_style("default_user", {"last_doc_type": "resume"})
|
||||
details = structured_brief or prompt or "Resume details provided by user."
|
||||
return r"""\documentclass{article}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{geometry}
|
||||
\geometry{a4paper, margin=0.75in}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\section*{Details}
|
||||
""" + details + r"""
|
||||
|
||||
\end{document}"""
|
||||
|
||||
base_doc = r"""\documentclass{article}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{geometry}
|
||||
\geometry{a4paper, margin=1in}
|
||||
|
||||
\begin{document}
|
||||
|
||||
"""
|
||||
content = structured_brief or prompt or ""
|
||||
base_doc += r"""\section*{Document}
|
||||
|
||||
""" + content + r"""
|
||||
|
||||
\end{document}"""
|
||||
|
||||
return base_doc
|
||||
|
||||
|
||||
def generate_latex_with_llm_stream(
|
||||
prompt: str,
|
||||
history: List[Dict[str, str]],
|
||||
style_profile: Dict[str, Any],
|
||||
document_type: str,
|
||||
template_hint: Optional[str] = None,
|
||||
current_latex: Optional[str] = None,
|
||||
structured_brief: Optional[str] = None,
|
||||
edit_mode: bool = False,
|
||||
):
|
||||
"""Stream LaTeX generation from LLM, yielding chunks as they arrive."""
|
||||
if not STREAMING_ENABLED:
|
||||
full_latex = generate_latex_with_llm(
|
||||
prompt, history, style_profile, document_type, template_hint, current_latex, structured_brief, edit_mode=edit_mode
|
||||
)
|
||||
chunk_size = 100
|
||||
for i in range(0, len(full_latex), chunk_size):
|
||||
yield full_latex[i:i + chunk_size]
|
||||
return
|
||||
if CLIENT_MODE == "langchain":
|
||||
messages: List[Dict[str, Any]] = [{"role": "system", "content": latex_system_prompt(style_profile, document_type, template_hint)}]
|
||||
messages.extend(history)
|
||||
messages.extend(latex_context_messages(template_hint, current_latex, structured_brief))
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
"[AI] Streaming from model=%s doc_type=%s template_hint=%s",
|
||||
SMART_MODEL,
|
||||
document_type,
|
||||
"yes" if template_hint else "no",
|
||||
)
|
||||
llm = get_chat_model(SMART_MODEL, streaming=True)
|
||||
if llm:
|
||||
start = time.perf_counter()
|
||||
total_chars = 0
|
||||
chunk_count = 0
|
||||
first_chunk = None
|
||||
for chunk in llm.stream(to_lc_messages(messages)):
|
||||
if chunk.content:
|
||||
if first_chunk is None:
|
||||
first_chunk = time.perf_counter()
|
||||
chunk_count += 1
|
||||
total_chars += len(str(chunk.content))
|
||||
yield chunk.content
|
||||
elapsed = time.perf_counter() - start
|
||||
logger.info(
|
||||
"[AI] latex-stream model=%s elapsed=%.2fs first_chunk=%.2fs chunks=%s chars=%s",
|
||||
SMART_MODEL,
|
||||
elapsed,
|
||||
(first_chunk - start) if first_chunk else -1.0,
|
||||
chunk_count,
|
||||
total_chars,
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error("[AI] LangChain streaming failed, falling back to mock: %s", exc)
|
||||
|
||||
# Fallback to non-streaming for mock mode
|
||||
full_latex = generate_latex_with_llm(
|
||||
prompt, history, style_profile, document_type, template_hint, current_latex, structured_brief, edit_mode=edit_mode
|
||||
)
|
||||
# Simulate streaming by yielding chunks
|
||||
chunk_size = 100
|
||||
for i in range(0, len(full_latex), chunk_size):
|
||||
yield full_latex[i:i + chunk_size]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"generate_outline_with_llm",
|
||||
"generate_section_draft",
|
||||
"generate_field_values",
|
||||
"generate_latex_with_llm",
|
||||
"generate_latex_with_llm_stream",
|
||||
"generate_template_fill_stream",
|
||||
]
|
||||
@@ -0,0 +1,964 @@
|
||||
import os
|
||||
import mimetypes
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
import queue
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from flask import Flask, jsonify, request, send_file, Response, stream_with_context
|
||||
from flask_cors import CORS
|
||||
import json
|
||||
|
||||
from ai_generation import (
|
||||
generate_latex_with_llm,
|
||||
generate_latex_with_llm_stream,
|
||||
generate_outline_with_llm,
|
||||
generate_section_draft,
|
||||
generate_field_values,
|
||||
generate_template_fill_stream,
|
||||
)
|
||||
from briefs import gather_brief, _preprocess_intent
|
||||
from config import (
|
||||
CLIENT_MODE,
|
||||
SMART_MODEL,
|
||||
OUTPUT_DIR,
|
||||
ASSETS_DIR,
|
||||
TEMPLATE_DIR,
|
||||
JAVA_BACKEND_URL,
|
||||
PREVIEW_MAX_INFLIGHT,
|
||||
get_chat_model,
|
||||
logger,
|
||||
)
|
||||
from langchain_utils import to_lc_messages
|
||||
from document_types import detect_document_type
|
||||
from latex_utils import apply_style_overrides, clean_generated_latex
|
||||
from pdf_utils import compile_latex_to_pdf, render_pdf_to_images
|
||||
from pdf_text_editor import convert_pdf_to_text_editor_document
|
||||
from storage import (
|
||||
load_user_style,
|
||||
load_user_templates,
|
||||
load_versions,
|
||||
save_user_style,
|
||||
save_user_template,
|
||||
save_version,
|
||||
)
|
||||
from styles import update_style_profile_from_prompt
|
||||
from vision import vision_layout_from_images
|
||||
from prompts import pdf_qa_system_prompt
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def log_job_request_sequence() -> None:
|
||||
job_id = request.headers.get("X-Job-Id")
|
||||
if not job_id:
|
||||
return
|
||||
seq = request.headers.get("X-Job-Seq", "?")
|
||||
total = request.headers.get("X-Job-Total", "?")
|
||||
logger.info("[HTTP] job_id=%s req=%s/%s %s %s", job_id, seq, total, request.method, request.path)
|
||||
|
||||
|
||||
def _json_body() -> Dict[str, Any]:
|
||||
return request.get_json(silent=True) or {}
|
||||
|
||||
def _require_ai_enabled() -> Optional[Any]:
|
||||
if CLIENT_MODE != "langchain":
|
||||
return jsonify({"error": "AI is disabled. Set OPENAI_API_KEY to enable AI features."}), 503
|
||||
return None
|
||||
|
||||
|
||||
def _java_url(path: str) -> str:
|
||||
base = JAVA_BACKEND_URL.rstrip("/")
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"{base}{path}"
|
||||
|
||||
|
||||
def _java_request_json(method: str, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
url = _java_url(path)
|
||||
data = None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8") if exc.fp else ""
|
||||
logger.error("[JAVA] %s %s failed status=%s detail=%s", method, path, exc.code, detail)
|
||||
raise
|
||||
|
||||
|
||||
def _fetch_ai_session(session_id: str) -> Dict[str, Any]:
|
||||
return _java_request_json("GET", f"/api/v1/ai/create/internal/sessions/{session_id}")
|
||||
|
||||
|
||||
def _update_ai_session(session_id: str, payload: Dict[str, Any]) -> None:
|
||||
_java_request_json("POST", f"/api/v1/ai/create/internal/sessions/{session_id}/update", payload)
|
||||
|
||||
|
||||
def _sanitize_doc_type(value: str) -> str:
|
||||
cleaned = re.sub(r"[^a-zA-Z0-9_]+", "", (value or "").lower())
|
||||
return cleaned or "miscellaneous"
|
||||
|
||||
|
||||
def _select_template(doc_type: str, template_id: Optional[str]) -> Optional[str]:
|
||||
safe_doc_type = _sanitize_doc_type(doc_type)
|
||||
base_dir = Path(TEMPLATE_DIR) / safe_doc_type
|
||||
if not base_dir.exists() or not base_dir.is_dir():
|
||||
return None
|
||||
|
||||
if template_id:
|
||||
safe_template = re.sub(r"[^a-zA-Z0-9_-]+", "", template_id)
|
||||
if safe_template:
|
||||
candidate = base_dir / f"{safe_template}.tex"
|
||||
if candidate.exists():
|
||||
return candidate.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
default_path = base_dir / "default.tex"
|
||||
if default_path.exists():
|
||||
return default_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
for tex_file in sorted(base_dir.glob("*.tex")):
|
||||
return tex_file.read_text(encoding="utf-8", errors="replace")
|
||||
return None
|
||||
|
||||
|
||||
@app.route("/api/intent/check", methods=["POST"])
|
||||
def intent_check() -> Any:
|
||||
try:
|
||||
data = _json_body()
|
||||
prompt: str = data.get("prompt", "")
|
||||
history: List[Dict[str, str]] = data.get("conversationHistory") or []
|
||||
current_latex: Optional[str] = data.get("currentLatex")
|
||||
current_pdf_url: Optional[str] = data.get("currentPdfUrl")
|
||||
doc_type = detect_document_type(prompt, current_latex)
|
||||
intent = _preprocess_intent(prompt, history, bool(current_pdf_url), current_latex)
|
||||
intent["documentType"] = doc_type
|
||||
intent["hasPdf"] = bool(current_pdf_url)
|
||||
return jsonify(intent)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("[INTENT] intent_check failed: %s", exc, exc_info=True)
|
||||
return jsonify({"wants_pdf": True, "has_enough_info": True, "allow_makeup": False, "reason": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route("/api/pdf/answer", methods=["POST"])
|
||||
def pdf_answer() -> Any:
|
||||
data = _json_body()
|
||||
pdf_url = data.get("pdfUrl")
|
||||
question = data.get("question")
|
||||
if not pdf_url or not question:
|
||||
return jsonify({"error": "Missing pdfUrl or question"}), 400
|
||||
|
||||
filename = os.path.basename(pdf_url.split("?")[0])
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
return jsonify({"error": "Invalid pdf file"}), 400
|
||||
|
||||
pdf_path = os.path.join(OUTPUT_DIR, filename)
|
||||
if not os.path.exists(pdf_path):
|
||||
return jsonify({"error": "PDF not found"}), 404
|
||||
|
||||
try:
|
||||
doc = convert_pdf_to_text_editor_document(pdf_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("[PDF-ANSWER] failed to parse pdf: %s", exc, exc_info=True)
|
||||
return jsonify({"error": "Failed to read PDF content"}), 500
|
||||
|
||||
pages = doc.get("document", {}).get("pages", []) if doc else []
|
||||
snippets: List[str] = []
|
||||
for page in pages:
|
||||
for elem in page.get("textElements", []) or []:
|
||||
text = elem.get("text")
|
||||
if text:
|
||||
snippets.append(str(text))
|
||||
if not snippets:
|
||||
return jsonify({"error": "No readable text in PDF"}), 400
|
||||
|
||||
# Normalize and limit context
|
||||
context = " ".join(snippets)
|
||||
context = " ".join(context.split()) # normalize whitespace
|
||||
max_context = 10000
|
||||
if len(context) > max_context:
|
||||
context = context[:max_context]
|
||||
|
||||
# Heuristic helpers
|
||||
def _sentences(text: str) -> List[str]:
|
||||
return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
|
||||
|
||||
def _heuristic_summary(text: str, limit: int = 480) -> str:
|
||||
sentences = _sentences(text)
|
||||
hits = [s for s in sentences if re.search(r"\b(difficult|challenge|problem|issue|hard)\b", s, re.IGNORECASE)]
|
||||
chosen = hits[:3] if hits else sentences[:3]
|
||||
summary = " ".join(chosen).strip()
|
||||
return summary[:limit] + ("…" if len(summary) > limit else "")
|
||||
|
||||
def _heuristic_first_difficulty(text: str) -> str:
|
||||
sentences = _sentences(text)
|
||||
for s in sentences:
|
||||
if re.search(r"\b(difficult|challenge|problem|issue|hard)\b", s, re.IGNORECASE):
|
||||
return s
|
||||
return sentences[0] if sentences else "No difficulty found in PDF text."
|
||||
|
||||
if CLIENT_MODE != "langchain":
|
||||
return jsonify({"error": "PDF Q&A unavailable (no AI client configured)."}), 503
|
||||
|
||||
model_name = SMART_MODEL
|
||||
system_prompt = pdf_qa_system_prompt()
|
||||
user_prompt = f"Question: {question}\n\nPDF text:\n{context}"
|
||||
try:
|
||||
llm = get_chat_model(model_name, max_tokens=220)
|
||||
if not llm:
|
||||
return jsonify({"error": "PDF Q&A unavailable (no AI client configured)."}), 503
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(
|
||||
to_lc_messages(
|
||||
[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
)
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[PDF-ANSWER] model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
model_name,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
answer = response.content
|
||||
if not answer or not str(answer).strip():
|
||||
answer = _heuristic_summary(context)
|
||||
# If model parrots title/metadata, replace with heuristic
|
||||
title_like = re.match(r"^why pdfs|^minimalist|^author:", answer.strip(), re.IGNORECASE) if answer else None
|
||||
normalized_answer = re.sub(r"\s+", " ", answer or "").strip().lower()
|
||||
normalized_context = re.sub(r"\s+", " ", context).strip().lower()
|
||||
copied_context = bool(normalized_answer) and normalized_answer in normalized_context
|
||||
if title_like or copied_context:
|
||||
answer = _heuristic_summary(context)
|
||||
return jsonify({"answer": answer, "mode": "model"})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("[PDF-ANSWER] model failed: %s", exc, exc_info=True)
|
||||
answer = _heuristic_first_difficulty(context)
|
||||
return jsonify({"answer": answer, "mode": "heuristic"})
|
||||
|
||||
|
||||
@app.route("/api/generate", methods=["POST"])
|
||||
def generate() -> Any:
|
||||
"""Generate LaTeX + PDF in a single call."""
|
||||
data = _json_body()
|
||||
user_id = data.get("userId", "default_user")
|
||||
prompt: str = data.get("prompt", "")
|
||||
history: List[Dict[str, str]] = data.get("conversationHistory") or []
|
||||
current_latex: str | None = data.get("currentLatex")
|
||||
skip_template = bool(data.get("skipTemplate"))
|
||||
force_new_document: Optional[bool] = data.get("forceNewDocument")
|
||||
edit_mode = bool(current_latex) and not bool(force_new_document)
|
||||
latex_source = current_latex if edit_mode else None
|
||||
|
||||
style_profile = update_style_profile_from_prompt(user_id, prompt)
|
||||
|
||||
doc_type = detect_document_type(prompt, latex_source or current_latex)
|
||||
brief = gather_brief(doc_type, prompt, history)
|
||||
if brief.get("needsInfo"):
|
||||
logger.info("[REQ] brief incomplete doc_type=%s missing=%s", doc_type, brief.get("missing"))
|
||||
return jsonify(
|
||||
{
|
||||
"needsInfo": True,
|
||||
"message": brief.get("message"),
|
||||
"missing": brief.get("missing", []),
|
||||
"collected": brief.get("collected", {}),
|
||||
"documentType": doc_type,
|
||||
}
|
||||
)
|
||||
|
||||
templates = load_user_templates(user_id)
|
||||
template_hint = None if (skip_template or edit_mode) else templates.get(doc_type)
|
||||
|
||||
logger.info(
|
||||
"[REQ] user=%s doc_type=%s template=%s history_len=%s current_latex=%s skip_template=%s edit_mode=%s",
|
||||
user_id,
|
||||
doc_type,
|
||||
"yes" if template_hint else "no",
|
||||
len(history),
|
||||
bool(current_latex),
|
||||
skip_template,
|
||||
edit_mode,
|
||||
)
|
||||
|
||||
mode = "live" if CLIENT_MODE == "langchain" else "mock"
|
||||
latex_code_raw = generate_latex_with_llm(
|
||||
prompt,
|
||||
history,
|
||||
style_profile,
|
||||
doc_type,
|
||||
template_hint,
|
||||
latex_source,
|
||||
brief.get("structured_brief"),
|
||||
edit_mode=edit_mode,
|
||||
)
|
||||
latex_code = apply_style_overrides(clean_generated_latex(latex_code_raw), style_profile)
|
||||
|
||||
doc_type = detect_document_type(prompt, latex_code)
|
||||
save_user_style(user_id, {"last_doc_type": doc_type})
|
||||
if not skip_template and not edit_mode:
|
||||
save_user_template(user_id, doc_type, latex_code)
|
||||
elif skip_template:
|
||||
logger.info("[TEMPLATE] skip flag set; not persisting template for %s", doc_type)
|
||||
else:
|
||||
logger.info("[TEMPLATE] edit mode active; not updating template for %s", doc_type)
|
||||
template_used = bool(template_hint)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
pdf_path = compile_latex_to_pdf(latex_code, job_id)
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
pdf_url = f"/output/{job_id}.pdf"
|
||||
version_entry = {
|
||||
"id": job_id,
|
||||
"prompt": prompt,
|
||||
"documentType": doc_type,
|
||||
"pdfUrl": pdf_url,
|
||||
"latex": latex_code,
|
||||
"createdAt": datetime.utcnow().isoformat() + "Z",
|
||||
"styleProfile": style_profile,
|
||||
"templateUsed": template_used,
|
||||
"editMode": edit_mode,
|
||||
}
|
||||
save_version(user_id, version_entry)
|
||||
|
||||
logger.info("[OK] job_id=%s doc_type=%s pdf_url=%s", job_id, doc_type, pdf_url)
|
||||
return jsonify(
|
||||
{
|
||||
"latex": latex_code,
|
||||
"pdfUrl": pdf_url,
|
||||
"documentType": doc_type,
|
||||
"message": f"Generated {doc_type} successfully! mode={mode}",
|
||||
"version": version_entry,
|
||||
"styleProfile": style_profile,
|
||||
"mode": mode,
|
||||
"templateUsed": template_used,
|
||||
"editingExisting": edit_mode,
|
||||
}
|
||||
)
|
||||
|
||||
logger.error("[FAIL] job_id=%s doc_type=%s compile_failed", job_id, doc_type)
|
||||
return jsonify(
|
||||
{
|
||||
"error": "LaTeX compilation failed. Check logs.",
|
||||
"latex": latex_code,
|
||||
"mode": mode,
|
||||
"editingExisting": edit_mode,
|
||||
}
|
||||
), 500
|
||||
|
||||
|
||||
@app.route("/api/generate_stream", methods=["POST"])
|
||||
def generate_stream() -> Any:
|
||||
"""Stream LaTeX generation and compile PDFs incrementally."""
|
||||
try:
|
||||
data = _json_body()
|
||||
user_id = data.get("userId", "default_user")
|
||||
prompt: str = data.get("prompt", "")
|
||||
history: List[Dict[str, str]] = data.get("conversationHistory") or []
|
||||
current_latex: str | None = data.get("currentLatex")
|
||||
skip_template = bool(data.get("skipTemplate"))
|
||||
force_new_document: Optional[bool] = data.get("forceNewDocument")
|
||||
edit_mode = bool(current_latex) and not bool(force_new_document)
|
||||
latex_source = current_latex if edit_mode else None
|
||||
style_profile = update_style_profile_from_prompt(user_id, prompt)
|
||||
doc_type = detect_document_type(prompt, latex_source or current_latex)
|
||||
brief = gather_brief(doc_type, prompt, history, current_latex, bool(current_latex))
|
||||
logger.info(
|
||||
"[STREAM] brief gate doc_type=%s needsInfo=%s missing=%s allowFabrication=%s",
|
||||
doc_type,
|
||||
brief.get("needsInfo"),
|
||||
brief.get("missing"),
|
||||
brief.get("allowFabrication"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("[STREAM] Failed to prepare generation request: %s", exc, exc_info=True)
|
||||
return jsonify({"error": "Failed to start generation", "detail": str(exc)}), 500
|
||||
|
||||
if brief.get("needsInfo"):
|
||||
# Return a normal 200 with guidance so the assistant can ask follow-up questions
|
||||
return jsonify(
|
||||
{
|
||||
"needsInfo": True,
|
||||
"message": brief.get("message"),
|
||||
"missing": brief.get("missing", []),
|
||||
"collected": brief.get("collected", {}),
|
||||
"documentType": doc_type,
|
||||
"allowFabrication": brief.get("allowFabrication", False),
|
||||
}
|
||||
)
|
||||
|
||||
templates = load_user_templates(user_id)
|
||||
template_hint = None if (skip_template or edit_mode) else templates.get(doc_type)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
accumulated_latex = ""
|
||||
last_compile_idx = 0
|
||||
compile_interval = 250 # Compile every ~250 characters for faster previews
|
||||
compile_time_budget = 2.0 # Or every ~2 seconds, whichever comes first
|
||||
last_compile_time = time.perf_counter()
|
||||
last_heartbeat_time = time.perf_counter()
|
||||
stream_start = time.perf_counter()
|
||||
first_chunk_time: Optional[float] = None
|
||||
last_chunk_time: Optional[float] = None
|
||||
total_chunk_chars = 0
|
||||
total_chunks = 0
|
||||
|
||||
preview_executor = ThreadPoolExecutor(max_workers=2)
|
||||
preview_tasks: Dict[str, Tuple[Any, int]] = {}
|
||||
chunk_queue: "queue.Queue[Tuple[str, Optional[str]]]" = queue.Queue()
|
||||
|
||||
def stream_latex():
|
||||
try:
|
||||
for chunk in generate_latex_with_llm_stream(
|
||||
prompt,
|
||||
history,
|
||||
style_profile,
|
||||
doc_type,
|
||||
template_hint,
|
||||
latex_source,
|
||||
brief.get("structured_brief"),
|
||||
edit_mode=edit_mode,
|
||||
):
|
||||
chunk_queue.put(("chunk", chunk))
|
||||
except Exception as exc:
|
||||
logger.error("[STREAM] LLM streaming failed: %s", exc, exc_info=True)
|
||||
chunk_queue.put(("error", str(exc)))
|
||||
finally:
|
||||
chunk_queue.put(("done", None))
|
||||
|
||||
def submit_preview(latex: str, progress: int) -> None:
|
||||
if len(preview_tasks) >= PREVIEW_MAX_INFLIGHT:
|
||||
return
|
||||
preview_job_id = f"{job_id}-preview-{progress}"
|
||||
|
||||
def _run_compile() -> Optional[str]:
|
||||
return compile_latex_to_pdf(latex, preview_job_id, log_errors=False)
|
||||
|
||||
fut = preview_executor.submit(_run_compile)
|
||||
preview_tasks[preview_job_id] = (fut, progress)
|
||||
|
||||
def drain_previews():
|
||||
nonlocal last_compile_idx, last_compile_time
|
||||
completed = []
|
||||
for pid, (fut, progress) in list(preview_tasks.items()):
|
||||
if fut.done():
|
||||
completed.append(pid)
|
||||
try:
|
||||
pdf_path = fut.result()
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
pdf_url = f"/output/{pid}.pdf"
|
||||
yield f"data: {json.dumps({'type': 'pdf_update', 'pdfUrl': pdf_url, 'progress': progress})}\n\n"
|
||||
last_compile_idx = progress
|
||||
last_compile_time = time.perf_counter()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("[STREAM] Preview compile failed (async): %s", e, exc_info=True)
|
||||
for pid in completed:
|
||||
preview_tasks.pop(pid, None)
|
||||
|
||||
def generate():
|
||||
nonlocal accumulated_latex, last_compile_time, last_heartbeat_time
|
||||
nonlocal total_chunk_chars, total_chunks, first_chunk_time, last_chunk_time
|
||||
|
||||
# Send initial metadata
|
||||
yield f"data: {json.dumps({'type': 'start', 'jobId': job_id, 'documentType': doc_type, 'editingExisting': edit_mode})}\n\n"
|
||||
|
||||
streamer = threading.Thread(target=stream_latex, daemon=True)
|
||||
streamer.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
item_type, payload = chunk_queue.get(timeout=0.5)
|
||||
except queue.Empty:
|
||||
now = time.perf_counter()
|
||||
if now - last_heartbeat_time >= 1.0:
|
||||
yield f"data: {json.dumps({'type': 'heartbeat', 'ts': now})}\n\n"
|
||||
last_heartbeat_time = now
|
||||
yield from drain_previews()
|
||||
continue
|
||||
|
||||
if item_type == "chunk":
|
||||
chunk = payload or ""
|
||||
accumulated_latex += chunk
|
||||
total_chunks += 1
|
||||
total_chunk_chars += len(chunk)
|
||||
last_chunk_time = time.perf_counter()
|
||||
if first_chunk_time is None:
|
||||
first_chunk_time = last_chunk_time
|
||||
yield f"data: {json.dumps({'type': 'latex_chunk', 'chunk': chunk, 'accumulated': accumulated_latex})}\n\n"
|
||||
|
||||
elapsed = time.perf_counter() - last_compile_time
|
||||
if (len(accumulated_latex) - last_compile_idx >= compile_interval) or elapsed >= compile_time_budget:
|
||||
compile_latex = accumulated_latex
|
||||
if "\\begin{document}" in compile_latex and "\\end{document}" not in compile_latex:
|
||||
if compile_latex.count("\\begin{") > compile_latex.count("\\end{"):
|
||||
temp_latex = compile_latex
|
||||
last_begin = compile_latex.rfind("\\begin{")
|
||||
if last_begin != -1:
|
||||
env_start = last_begin + len("\\begin{")
|
||||
env_end = compile_latex.find("}", env_start)
|
||||
if env_end != -1:
|
||||
env_name = compile_latex[env_start:env_end]
|
||||
temp_latex += f"\\end{{{env_name}}}\n"
|
||||
temp_latex += "\\end{document}\n"
|
||||
compile_latex = temp_latex
|
||||
else:
|
||||
compile_latex += "\\end{document}\n"
|
||||
|
||||
if "\\begin{document}" in compile_latex and "\\end{document}" in compile_latex:
|
||||
submit_preview(compile_latex, len(accumulated_latex))
|
||||
|
||||
now = time.perf_counter()
|
||||
if now - last_heartbeat_time >= 1.0:
|
||||
yield f"data: {json.dumps({'type': 'heartbeat', 'ts': now})}\n\n"
|
||||
last_heartbeat_time = now
|
||||
yield from drain_previews()
|
||||
|
||||
elif item_type == "error":
|
||||
message = payload or "Streaming failed"
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': message})}\n\n"
|
||||
break
|
||||
elif item_type == "done":
|
||||
break
|
||||
|
||||
stream_end = time.perf_counter()
|
||||
if first_chunk_time is None:
|
||||
logger.info(
|
||||
"[STREAM] LLM finished with no chunks job_id=%s elapsed=%.2fs",
|
||||
job_id,
|
||||
stream_end - stream_start,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[STREAM] LLM stats job_id=%s chunks=%s chars=%s first_chunk=%.2fs last_chunk=%.2fs elapsed=%.2fs",
|
||||
job_id,
|
||||
total_chunks,
|
||||
total_chunk_chars,
|
||||
first_chunk_time - stream_start,
|
||||
(last_chunk_time or stream_end) - stream_start,
|
||||
stream_end - stream_start,
|
||||
)
|
||||
|
||||
# Final compilation with complete LaTeX
|
||||
latex_code = apply_style_overrides(clean_generated_latex(accumulated_latex), style_profile)
|
||||
final_doc_type = detect_document_type(prompt, latex_code)
|
||||
save_user_style(user_id, {"last_doc_type": final_doc_type})
|
||||
if not skip_template and not edit_mode:
|
||||
save_user_template(user_id, final_doc_type, latex_code)
|
||||
elif skip_template:
|
||||
logger.info("[TEMPLATE] skip flag set; not persisting template for %s", final_doc_type)
|
||||
else:
|
||||
logger.info("[TEMPLATE] edit mode active; not updating template for %s", final_doc_type)
|
||||
template_used = bool(template_hint)
|
||||
|
||||
pdf_path = compile_latex_to_pdf(latex_code, job_id)
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
pdf_url = f"/output/{job_id}.pdf"
|
||||
version_entry = {
|
||||
"id": job_id,
|
||||
"prompt": prompt,
|
||||
"documentType": final_doc_type,
|
||||
"pdfUrl": pdf_url,
|
||||
"latex": latex_code,
|
||||
"createdAt": datetime.utcnow().isoformat() + "Z",
|
||||
"styleProfile": style_profile,
|
||||
"templateUsed": template_used,
|
||||
"editMode": edit_mode,
|
||||
}
|
||||
save_version(user_id, version_entry)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'complete', 'pdfUrl': pdf_url, 'latex': latex_code, 'version': version_entry, 'documentType': final_doc_type, 'templateUsed': template_used, 'styleProfile': style_profile, 'editingExisting': edit_mode})}\n\n"
|
||||
else:
|
||||
logger.error("[STREAM] Final PDF compilation failed for job_id=%s", job_id)
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'Final PDF compilation failed'})}\n\n"
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("[STREAM] Generation error job_id=%s: %s", job_id, e, exc_info=True)
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
finally:
|
||||
try:
|
||||
preview_executor.shutdown(wait=False, cancel_futures=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("[STREAM] Failed to start response: %s", exc, exc_info=True)
|
||||
return jsonify({"error": "Unable to start streaming response", "detail": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route("/api/create/sessions/<session_id>/stream", methods=["GET"])
|
||||
def create_stream(session_id: str) -> Any:
|
||||
disabled = _require_ai_enabled()
|
||||
if disabled:
|
||||
return disabled
|
||||
phase = (request.args.get("phase") or "outline").strip().lower()
|
||||
try:
|
||||
session = _fetch_ai_session(session_id)
|
||||
except Exception: # noqa: BLE001
|
||||
return jsonify({"error": "Session not found"}), 404
|
||||
|
||||
user_id = session.get("userId", "default_user")
|
||||
prompt = session.get("promptLatest") or session.get("promptInitial") or ""
|
||||
doc_type = session.get("docType") or detect_document_type(prompt, None)
|
||||
template_id = session.get("templateId")
|
||||
outline_text = session.get("outlineText") or ""
|
||||
constraints = session.get("outlineConstraints")
|
||||
if isinstance(constraints, str) and constraints.strip():
|
||||
try:
|
||||
constraints = json.loads(constraints)
|
||||
except json.JSONDecodeError:
|
||||
constraints = None
|
||||
draft_sections_raw = session.get("draftSections")
|
||||
draft_sections = None
|
||||
if isinstance(draft_sections_raw, list):
|
||||
draft_sections = draft_sections_raw
|
||||
elif isinstance(draft_sections_raw, str) and draft_sections_raw.strip():
|
||||
try:
|
||||
draft_sections = json.loads(draft_sections_raw)
|
||||
except json.JSONDecodeError:
|
||||
draft_sections = None
|
||||
style_profile = load_user_style(user_id)
|
||||
|
||||
def sse(data: Dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(data)}\n\n"
|
||||
|
||||
def generate():
|
||||
yield sse({"type": "phase_changed", "phase": phase})
|
||||
|
||||
if phase == "outline":
|
||||
outline = generate_outline_with_llm(prompt, doc_type, constraints)
|
||||
_update_ai_session(
|
||||
session_id,
|
||||
{
|
||||
"outlineText": outline,
|
||||
"outlineConstraints": json.dumps(constraints, ensure_ascii=True) if constraints else None,
|
||||
"docType": doc_type,
|
||||
"status": "OUTLINE_PENDING",
|
||||
},
|
||||
)
|
||||
yield sse({"type": "outline_ready", "outlineText": outline})
|
||||
yield sse({"type": "phase_complete", "phase": "outline"})
|
||||
return
|
||||
|
||||
if phase == "draft":
|
||||
base_outline = outline_text or prompt
|
||||
sections = generate_section_draft(prompt, doc_type, base_outline, constraints)
|
||||
_update_ai_session(
|
||||
session_id,
|
||||
{
|
||||
"draftSections": json.dumps(sections, ensure_ascii=True),
|
||||
"outlineConstraints": json.dumps(constraints, ensure_ascii=True) if constraints else None,
|
||||
"docType": doc_type,
|
||||
"status": "DRAFT_READY",
|
||||
},
|
||||
)
|
||||
yield sse({"type": "draft_sections", "sections": sections})
|
||||
yield sse({"type": "phase_complete", "phase": "draft", "sections": sections})
|
||||
return
|
||||
|
||||
if phase == "polish":
|
||||
accumulated = ""
|
||||
template_latex = _select_template(doc_type, template_id)
|
||||
if template_latex:
|
||||
for chunk in generate_template_fill_stream(
|
||||
template_latex,
|
||||
doc_type,
|
||||
outline_text or prompt,
|
||||
draft_sections=draft_sections,
|
||||
constraints=constraints,
|
||||
style_profile=style_profile,
|
||||
):
|
||||
accumulated += chunk
|
||||
yield sse({"type": "latex_delta", "phase": "polish", "delta": chunk})
|
||||
else:
|
||||
section_text = ""
|
||||
if draft_sections:
|
||||
section_text = "\n".join(
|
||||
f"{section.get('label', 'Section')}: {section.get('value', '')}"
|
||||
for section in draft_sections
|
||||
)
|
||||
constraint_text = ""
|
||||
if constraints:
|
||||
tone = constraints.get("tone")
|
||||
audience = constraints.get("audience")
|
||||
pages = constraints.get("pageCount")
|
||||
constraint_text = f"Tone: {tone}. Audience: {audience}. Target pages: {pages}."
|
||||
polish_prompt = (
|
||||
f"Create a polished LaTeX document for a {doc_type}.\n"
|
||||
"Use the provided section content and keep the substance consistent.\n"
|
||||
f"{constraint_text}\n"
|
||||
"Keep the final document within the target page count.\n"
|
||||
)
|
||||
for chunk in generate_latex_with_llm_stream(
|
||||
polish_prompt,
|
||||
[],
|
||||
style_profile,
|
||||
doc_type,
|
||||
None,
|
||||
None,
|
||||
section_text or outline_text or prompt,
|
||||
edit_mode=True,
|
||||
):
|
||||
accumulated += chunk
|
||||
yield sse({"type": "latex_delta", "phase": "polish", "delta": chunk})
|
||||
|
||||
accumulated = apply_style_overrides(accumulated, style_profile)
|
||||
_update_ai_session(
|
||||
session_id,
|
||||
{"polishedLatex": accumulated, "docType": doc_type, "status": "POLISHED_READY"},
|
||||
)
|
||||
|
||||
pdf_job_id = f"{session_id}-polished"
|
||||
pdf_path = compile_latex_to_pdf(accumulated, pdf_job_id, log_errors=False)
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
pdf_url = f"/output/{pdf_job_id}.pdf"
|
||||
yield sse({"type": "save_complete", "docId": session_id, "pdfUrl": pdf_url})
|
||||
|
||||
yield sse({"type": "phase_complete", "phase": "polish", "latex": accumulated})
|
||||
return
|
||||
|
||||
yield sse({"type": "error", "message": f"Unknown phase: {phase}"})
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/create/sessions/<session_id>/fields", methods=["POST"])
|
||||
def fill_fields(session_id: str) -> Any:
|
||||
disabled = _require_ai_enabled()
|
||||
if disabled:
|
||||
return disabled
|
||||
try:
|
||||
logger.info("[AI create] fill_fields session_id=%s", session_id)
|
||||
session = _fetch_ai_session(session_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("[AI create] fill_fields session lookup failed session_id=%s error=%s", session_id, exc)
|
||||
return jsonify({"error": "Session not found"}), 404
|
||||
|
||||
data = _json_body()
|
||||
fields = data.get("fields") or []
|
||||
extra_prompt = data.get("extraPrompt") or ""
|
||||
if not isinstance(fields, list):
|
||||
return jsonify({"error": "Fields must be a list"}), 400
|
||||
|
||||
prompt = session.get("promptLatest") or session.get("promptInitial") or ""
|
||||
if extra_prompt:
|
||||
prompt = f"{prompt}\n{extra_prompt}"
|
||||
doc_type = session.get("docType") or detect_document_type(prompt, None)
|
||||
constraints = session.get("outlineConstraints")
|
||||
if isinstance(constraints, str) and constraints.strip():
|
||||
try:
|
||||
constraints = json.loads(constraints)
|
||||
except json.JSONDecodeError:
|
||||
constraints = None
|
||||
|
||||
filled = generate_field_values(prompt, doc_type, fields, constraints)
|
||||
return jsonify({"fields": filled})
|
||||
|
||||
|
||||
|
||||
|
||||
@app.route("/api/progressive_render", methods=["POST"])
|
||||
def progressive_render() -> Any:
|
||||
"""Compile arbitrary LaTeX (partial or masked) for progressive previews."""
|
||||
data = _json_body()
|
||||
latex = data.get("latex")
|
||||
if not latex or not isinstance(latex, str):
|
||||
return jsonify({"error": "Missing LaTeX payload"}), 400
|
||||
|
||||
job_id = data.get("jobId") or str(uuid.uuid4())
|
||||
pdf_path = compile_latex_to_pdf(latex, job_id)
|
||||
if pdf_path and os.path.exists(pdf_path):
|
||||
return jsonify({"pdfUrl": f"/output/{job_id}.pdf"})
|
||||
|
||||
return jsonify({"error": "Progressive compilation failed"}), 500
|
||||
|
||||
|
||||
@app.route("/output/<path:filename>", methods=["GET"])
|
||||
def serve_output_file(filename: str) -> Any:
|
||||
"""Serve generated PDF files and stored assets."""
|
||||
file_path = os.path.join(OUTPUT_DIR, filename)
|
||||
if os.path.exists(file_path):
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
return send_file(file_path, mimetype=mime_type or "application/octet-stream")
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
|
||||
@app.route("/api/versions/<user_id>", methods=["GET"])
|
||||
def list_versions(user_id: str) -> Any:
|
||||
return jsonify({"versions": load_versions(user_id)})
|
||||
|
||||
|
||||
@app.route("/api/style/<user_id>", methods=["GET"])
|
||||
def get_style(user_id: str) -> Any:
|
||||
return jsonify({"style": load_user_style(user_id)})
|
||||
|
||||
|
||||
@app.route("/api/style/<user_id>", methods=["POST"])
|
||||
def update_style(user_id: str) -> Any:
|
||||
data = _json_body()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "Style payload must be an object"}), 400
|
||||
current = load_user_style(user_id) or {}
|
||||
merged = {**current, **data}
|
||||
save_user_style(user_id, merged)
|
||||
return jsonify({"style": merged})
|
||||
|
||||
|
||||
@app.route("/api/style/apply", methods=["POST"])
|
||||
def apply_style() -> Any:
|
||||
data = _json_body()
|
||||
latex = data.get("latex")
|
||||
style = data.get("style") or {}
|
||||
if not latex or not isinstance(latex, str):
|
||||
return jsonify({"error": "Missing LaTeX payload"}), 400
|
||||
if not isinstance(style, dict):
|
||||
return jsonify({"error": "Style payload must be an object"}), 400
|
||||
updated = apply_style_overrides(latex, style)
|
||||
return jsonify({"latex": updated})
|
||||
|
||||
|
||||
@app.route("/api/import_template", methods=["POST"])
|
||||
def import_template() -> Any:
|
||||
"""Accept a PDF upload, extract layout via vision model, and save as a template."""
|
||||
user_id = request.form.get("userId", "default_user")
|
||||
doc_type = request.form.get("docType", "document")
|
||||
file = request.files.get("file")
|
||||
|
||||
if not file:
|
||||
return jsonify({"error": "No file uploaded"}), 400
|
||||
|
||||
pdf_bytes = file.read()
|
||||
images = render_pdf_to_images(pdf_bytes, max_pages=2, dpi=170)
|
||||
if not images:
|
||||
return jsonify({"error": "Failed to render PDF"}), 400
|
||||
|
||||
layout_latex = vision_layout_from_images(images, doc_type) or ""
|
||||
if not layout_latex:
|
||||
layout_latex = f"""\\documentclass{{article}}
|
||||
\\usepackage[margin=1in]{{geometry}}
|
||||
\\usepackage{{tabularx}}
|
||||
\\usepackage{{multicol}}
|
||||
\\begin{{document}}
|
||||
% Fallback template for {doc_type}
|
||||
\\section*{{Title placeholder}}
|
||||
Body text goes here.
|
||||
\\end{{document}}
|
||||
"""
|
||||
|
||||
sanitized = clean_generated_latex(layout_latex)
|
||||
save_user_template(user_id, doc_type, sanitized)
|
||||
return jsonify({"message": "Template imported", "docType": doc_type, "pages": len(images)})
|
||||
|
||||
|
||||
@app.route("/api/assets/upload", methods=["POST"])
|
||||
def upload_asset() -> Any:
|
||||
file = request.files.get("file")
|
||||
if not file:
|
||||
return jsonify({"error": "Missing file"}), 400
|
||||
|
||||
_, ext = os.path.splitext(file.filename or "")
|
||||
ext = ext.lower()
|
||||
if ext not in {".png", ".jpg", ".jpeg", ".gif"}:
|
||||
return jsonify({"error": "Unsupported file type"}), 400
|
||||
|
||||
asset_id = f"{uuid.uuid4().hex}{ext}"
|
||||
output_path = os.path.join(ASSETS_DIR, asset_id)
|
||||
os.makedirs(ASSETS_DIR, exist_ok=True)
|
||||
file.save(output_path)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"assetId": asset_id,
|
||||
"assetUrl": f"/output/assets/{asset_id}",
|
||||
"latexPath": f"assets/{asset_id}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/pdf-editor/document", methods=["GET"])
|
||||
def pdf_editor_document() -> Any:
|
||||
"""Expose a JSON snapshot of the PDF for rich text editing."""
|
||||
pdf_url = request.args.get("pdfUrl")
|
||||
if not pdf_url:
|
||||
return jsonify({"error": "Missing pdfUrl"}), 400
|
||||
|
||||
filename = os.path.basename(pdf_url.split("?")[0])
|
||||
if not filename:
|
||||
return jsonify({"error": "Invalid pdf file"}), 400
|
||||
if not filename.lower().endswith(".pdf"):
|
||||
return jsonify({"error": "Invalid pdf file"}), 400
|
||||
|
||||
pdf_path = os.path.join(OUTPUT_DIR, filename)
|
||||
if not os.path.exists(pdf_path):
|
||||
return jsonify({"error": "PDF not found"}), 404
|
||||
|
||||
try:
|
||||
document = convert_pdf_to_text_editor_document(pdf_path)
|
||||
return jsonify(document)
|
||||
except FileNotFoundError:
|
||||
return jsonify({"error": "Conversion failed"}), 500
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.error("[PDF-EDITOR] Conversion failed: %s", exc)
|
||||
return jsonify({"error": "Conversion failed"}), 500
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("[PDF-EDITOR] Unexpected conversion failure: %s", exc)
|
||||
return jsonify({"error": "Conversion failed"}), 500
|
||||
|
||||
|
||||
@app.route("/api/pdf-editor/upload", methods=["POST"])
|
||||
def pdf_editor_upload() -> Any:
|
||||
"""Accept an edited PDF and save it so the preview can refresh."""
|
||||
file = request.files.get("file")
|
||||
if not file:
|
||||
return jsonify({"error": "Missing file"}), 400
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
filename = f"{job_id}-edited.pdf"
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
file.save(output_path)
|
||||
|
||||
logger.info("[PDF-EDITOR] uploaded edited PDF job_id=%s -> %s", job_id, filename)
|
||||
return jsonify({"pdfUrl": f"/output/{filename}"})
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health() -> Any:
|
||||
return jsonify({"status": "ok", "engine": "pdflatex"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
@@ -0,0 +1,525 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
import time
|
||||
|
||||
from config import CLIENT_MODE, FAST_MODEL, SMART_MODEL, get_chat_model, logger
|
||||
from langchain_utils import to_lc_messages
|
||||
from prompts import brief_missing_info_system_prompt
|
||||
|
||||
|
||||
BRIEF_SCHEMAS: Dict[str, Dict[str, Any]] = {
|
||||
"resume": {
|
||||
"field_order": [
|
||||
"name",
|
||||
"contact",
|
||||
"location",
|
||||
"target_role",
|
||||
"summary",
|
||||
"work_history",
|
||||
"education",
|
||||
"skills",
|
||||
"achievements",
|
||||
"links",
|
||||
"constraints",
|
||||
],
|
||||
"labels": {
|
||||
"name": ["name", "full name"],
|
||||
"contact": ["contact", "contact info", "contact information", "email/phone"],
|
||||
"location": ["location", "city/country"],
|
||||
"target_role": ["target role", "role", "title", "headline"],
|
||||
"summary": ["summary", "objective", "about"],
|
||||
"work_history": ["experience", "work history", "roles"],
|
||||
"education": ["education", "studies"],
|
||||
"skills": ["skills", "stack"],
|
||||
"achievements": ["achievements", "certifications", "awards"],
|
||||
"links": ["links", "profiles", "linkedin/github"],
|
||||
"constraints": ["constraints", "tone/length/style"],
|
||||
},
|
||||
"questions": {
|
||||
"name": "What's your name as you'd like it on the page?",
|
||||
"contact": "How can someone reach you (email/phone)?",
|
||||
"location": "Where are you based (or remote)?",
|
||||
"target_role": "What role/title and industry are you aiming for?",
|
||||
"summary": "Give me a 1–2 sentence summary about you.",
|
||||
"work_history": "Recent roles: company, title, dates, location, and a few bullets with impact.",
|
||||
"education": "Degree(s), school, and graduation year?",
|
||||
"skills": "Key skills/stack (tech + relevant soft skills)?",
|
||||
"achievements": "Awards/certifications/major achievements?",
|
||||
"links": "Any LinkedIn/GitHub/portfolio links?",
|
||||
"constraints": "Any tone/length constraints (ATS, one-page, etc.)?",
|
||||
},
|
||||
"intro": "Hey! To build a strong resume, you can paste your old resume or just dump everything you remember—name, how to reach you, where you're based, what you're aiming for, your roles, education, skills, links. Share whatever you have and I'll work with it.",
|
||||
},
|
||||
"invoice": {
|
||||
"field_order": [
|
||||
"your_business",
|
||||
"client",
|
||||
"issue_date",
|
||||
"due_date",
|
||||
"line_items",
|
||||
"currency",
|
||||
"payment_terms",
|
||||
"notes",
|
||||
"constraints",
|
||||
],
|
||||
"labels": {
|
||||
"your_business": ["your business", "seller", "from"],
|
||||
"client": ["client", "bill to"],
|
||||
"issue_date": ["issue date", "invoice date"],
|
||||
"due_date": ["due date"],
|
||||
"line_items": ["line items", "services/items"],
|
||||
"currency": ["currency"],
|
||||
"payment_terms": ["payment terms"],
|
||||
"notes": ["notes"],
|
||||
"constraints": ["constraints", "layout/style"],
|
||||
},
|
||||
"questions": {
|
||||
"your_business": "Who is issuing the invoice (business name + contact)?",
|
||||
"client": "Who is being billed (name + contact)?",
|
||||
"issue_date": "Invoice issue date?",
|
||||
"due_date": "Due date?",
|
||||
"line_items": "Line items with description, qty, rate, tax (if any)?",
|
||||
"currency": "Currency?",
|
||||
"payment_terms": "Payment terms and payment methods?",
|
||||
"notes": "Notes to include (late fees, thank you, PO #)?",
|
||||
"constraints": "Branding/layout preferences?",
|
||||
},
|
||||
"intro": "I'll draft an accurate invoice if I know who is billing, who is paying, and the line items. Paste an old invoice or list the details.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def classify_intent_with_llm(prompt: str, history: List[Dict[str, str]], current_latex: Optional[str], has_pdf: bool) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Use a small model to classify intent instead of brittle regex.
|
||||
|
||||
Returns a dict like:
|
||||
{
|
||||
"documentType": "invoice|resume|contract|letter|report|form|document",
|
||||
"action": "new|edit|question",
|
||||
"allowFabrication": bool,
|
||||
"wantsPdf": bool,
|
||||
"hasEnoughInfo": bool,
|
||||
"missingFields": [str],
|
||||
"notes": str
|
||||
}
|
||||
"""
|
||||
if CLIENT_MODE != "langchain":
|
||||
logger.info("[INTENT] skip llm classify: client_mode=%s", CLIENT_MODE)
|
||||
return None
|
||||
|
||||
system = (
|
||||
"You classify user requests about documents. "
|
||||
"Output strict JSON. "
|
||||
"documentType must be one of: academic, agenda, brochure, business_card, case_study, checklist, "
|
||||
"contract, creative, datasheet, document, flyer, invoice, letter, manual, menu, minutes, newsletter, "
|
||||
"one_pager, poster, presentation, press_release, proposal, recipe, report, resume, timeline, whitepaper. "
|
||||
"action: 'new' (make/generate), 'edit' (modify existing), 'question' (asking about it). "
|
||||
"allowFabrication: true if the user invites making up/placeholder/dummy/random details "
|
||||
"OR asks you to use your knowledge about a fictional/real character (e.g., 'use what you know about James Bond', "
|
||||
"'make it for agent 007', 'create resume for Sherlock Holmes', etc.). "
|
||||
"Basically, if they're NOT providing their own personal details and expect you to fill in from common knowledge or imagination, set this to true. "
|
||||
"wantsPdf: true if they expect/gave permission to generate a PDF. "
|
||||
"hasEnoughInfo: true if there is enough info to proceed without asking questions (or if allowFabrication is true). "
|
||||
"missingFields: key details still needed (e.g., for invoice: seller, client, line items; resume: name, contact, work). "
|
||||
"notes: short free-form note."
|
||||
)
|
||||
|
||||
conversation = [{"role": "system", "content": system}]
|
||||
# Trim history to keep request small
|
||||
trimmed_history = history[-6:] if len(history) > 6 else history
|
||||
for msg in trimmed_history:
|
||||
if msg.get("content") and msg.get("role") in {"user", "assistant", "system"}:
|
||||
conversation.append({"role": msg["role"], "content": msg["content"]})
|
||||
conversation.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
llm = get_chat_model(
|
||||
FAST_MODEL or SMART_MODEL,
|
||||
max_tokens=800,
|
||||
model_kwargs={"response_format": {"type": "json_object"}},
|
||||
)
|
||||
if not llm:
|
||||
logger.info("[INTENT] skip llm classify: no LangChain client")
|
||||
return None
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(to_lc_messages(conversation))
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[INTENT] llm_classify model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
FAST_MODEL or SMART_MODEL,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
content = response.content
|
||||
if not content:
|
||||
logger.info("[INTENT] llm_classify empty content")
|
||||
return None
|
||||
data = json.loads(content)
|
||||
# Normalize
|
||||
doc_type = str(data.get("documentType") or "document").lower()
|
||||
allowed_types = {
|
||||
"academic", "agenda", "brochure", "business_card", "case_study", "checklist",
|
||||
"contract", "creative", "datasheet", "document", "flyer", "invoice", "letter",
|
||||
"manual", "menu", "minutes", "newsletter", "one_pager", "poster", "presentation",
|
||||
"press_release", "proposal", "recipe", "report", "resume", "timeline", "whitepaper"
|
||||
}
|
||||
if doc_type not in allowed_types:
|
||||
doc_type = "document"
|
||||
action = str(data.get("action") or "new").lower()
|
||||
if action not in {"new", "edit", "question"}:
|
||||
action = "new"
|
||||
result = {
|
||||
"documentType": doc_type,
|
||||
"action": action,
|
||||
"allowFabrication": bool(data.get("allowFabrication")),
|
||||
"wantsPdf": bool(data.get("wantsPdf", True)),
|
||||
"hasEnoughInfo": bool(data.get("hasEnoughInfo", True)),
|
||||
"missingFields": data.get("missingFields") or [],
|
||||
"notes": data.get("notes") or "",
|
||||
}
|
||||
logger.info(
|
||||
"[INTENT] llm_classify doc_type=%s action=%s allowFabrication=%s wantsPdf=%s hasEnoughInfo=%s missing=%s notes=%s",
|
||||
result["documentType"],
|
||||
result["action"],
|
||||
result["allowFabrication"],
|
||||
result["wantsPdf"],
|
||||
result["hasEnoughInfo"],
|
||||
result["missingFields"],
|
||||
(result["notes"] or "")[:120],
|
||||
)
|
||||
return result
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("[INTENT] LLM classify failed: %s", exc, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def detect_fabrication_opt_in(prompt: str, history: List[Dict[str, str]]) -> bool:
|
||||
"""
|
||||
Ask a small model to decide if the user has permitted invention of missing details.
|
||||
|
||||
Returns True when the user says to make things up / whatever is fine /
|
||||
no preference, even if they haven't provided concrete fields.
|
||||
"""
|
||||
if CLIENT_MODE != "langchain":
|
||||
return False
|
||||
|
||||
system = (
|
||||
"Decide if the user has explicitly permitted you to invent or make up missing details. "
|
||||
"Reply with strict JSON: {\"allowFabrication\": true|false}. "
|
||||
"Consider any user instruction like 'make it up', 'whatever you want', 'use dummy info', "
|
||||
"'fabricate the rest', 'fill in anything' as permission. "
|
||||
"Do not require specific keywords; infer intent from the conversation. "
|
||||
"If unclear, set allowFabrication to false."
|
||||
)
|
||||
|
||||
conversation = [{"role": "system", "content": system}]
|
||||
trimmed_history = history[-8:] if len(history) > 8 else history
|
||||
for msg in trimmed_history:
|
||||
if msg.get("content") and msg.get("role") in {"user", "assistant", "system"}:
|
||||
conversation.append({"role": msg["role"], "content": msg["content"]})
|
||||
conversation.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
llm = get_chat_model(
|
||||
FAST_MODEL or SMART_MODEL,
|
||||
max_tokens=100,
|
||||
model_kwargs={"response_format": {"type": "json_object"}},
|
||||
)
|
||||
if not llm:
|
||||
return False
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(to_lc_messages(conversation))
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[INTENT] fabrication-check model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
FAST_MODEL or SMART_MODEL,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
content = response.content
|
||||
if not content:
|
||||
return False
|
||||
data = json.loads(content)
|
||||
return bool(data.get("allowFabrication"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("[INTENT] fabrication opt-in check failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _preprocess_intent(
|
||||
prompt: str,
|
||||
history: List[Dict[str, str]],
|
||||
has_pdf: bool,
|
||||
current_latex: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Lightweight intent classifier used by /api/intent/check.
|
||||
|
||||
It is intentionally heuristic-only to avoid extra model calls. The goal is to
|
||||
decide whether we should proceed with PDF generation and whether it's OK to
|
||||
fabricate placeholder content when the user explicitly asks for it.
|
||||
"""
|
||||
user_texts = [entry.get("content", "") for entry in history if entry.get("role") == "user"]
|
||||
user_texts.append(prompt or "")
|
||||
combined_text = " ".join([t for t in user_texts if t]).strip().lower()
|
||||
|
||||
llm = classify_intent_with_llm(prompt, history, current_latex, has_pdf)
|
||||
if llm:
|
||||
return {
|
||||
"wants_pdf": llm.get("wantsPdf", True),
|
||||
"has_enough_info": llm.get("hasEnoughInfo", True),
|
||||
"allow_makeup": llm.get("allowFabrication", False),
|
||||
"document_type": llm.get("documentType"),
|
||||
"missing_fields": llm.get("missingFields", []),
|
||||
}
|
||||
|
||||
# Fallback heuristics (only if no LLM)
|
||||
avoid_pdf = bool(re.search(r"\b(no pdf|text only|markdown only|dont (make|generate) pdf)\b", combined_text))
|
||||
wants_pdf = not avoid_pdf or bool(current_latex) or has_pdf
|
||||
has_meaningful_text = len(combined_text) > 20
|
||||
return {
|
||||
"wants_pdf": wants_pdf,
|
||||
"has_enough_info": bool(current_latex or has_pdf or has_meaningful_text),
|
||||
"allow_makeup": False,
|
||||
"document_type": None,
|
||||
"missing_fields": [],
|
||||
}
|
||||
|
||||
def _extract_structured_fields(text: str, schema: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Naively parse user text to pull schema fields."""
|
||||
found: Dict[str, str] = {}
|
||||
lower = text.lower()
|
||||
|
||||
if schema.get("field_order") == BRIEF_SCHEMAS["resume"]["field_order"]:
|
||||
work_matches = re.findall(
|
||||
r"(?:experience|work history|role|company|position)\s*[:\-]\s*(.+?)(?=\n\n|\Z)",
|
||||
text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if work_matches:
|
||||
found["work_history"] = "\n".join(work_matches[:3])
|
||||
education_matches = re.findall(
|
||||
r"(?:education|degree)\s*[:\-]\s*(.+?)(?=\n\n|\Z)",
|
||||
text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if education_matches:
|
||||
found["education"] = "\n".join(education_matches[:2])
|
||||
skills_match = re.search(r"(?:skills|stack)\s*[:\-]\s*(.+)", text, flags=re.IGNORECASE)
|
||||
if skills_match:
|
||||
found["skills"] = skills_match.group(1).strip()
|
||||
|
||||
for field, labels in schema.get("labels", {}).items():
|
||||
for label in labels:
|
||||
pattern = rf"{label}\s*[:\-]\s*(.+?)(?=\n[A-Z][a-zA-Z ]+[:\-]|\Z)"
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
found[field] = match.group(1).strip()
|
||||
break
|
||||
if not found.get("summary") and len(lower) < 200:
|
||||
found["summary"] = text.strip()
|
||||
return {k: v for k, v in found.items() if v}
|
||||
|
||||
|
||||
def _format_missing_message(
|
||||
doc_type: str,
|
||||
schema: Dict[str, Any],
|
||||
collected: Dict[str, str],
|
||||
missing: List[str],
|
||||
preface: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Fallback text asking the user for missing fields."""
|
||||
intro = schema.get("intro") or f"Need a few details to finish your {doc_type}."
|
||||
lines = [intro]
|
||||
if preface:
|
||||
lines.append(preface)
|
||||
if collected:
|
||||
lines.append("Already have:")
|
||||
for field, value in collected.items():
|
||||
label = schema.get("labels", {}).get(field, [field])[0]
|
||||
lines.append(f"- {label}: {value}")
|
||||
if missing:
|
||||
lines.append("Still need:")
|
||||
questions = schema.get("questions", {})
|
||||
for field in missing[:4]:
|
||||
ask = questions.get(field) or f"{field}?"
|
||||
lines.append(f"- {ask}")
|
||||
lines.append("Partial info is fine—share whatever you remember.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _ai_missing_message(
|
||||
doc_type: str,
|
||||
schema: Dict[str, Any],
|
||||
collected: Dict[str, str],
|
||||
missing: List[str],
|
||||
) -> Optional[str]:
|
||||
"""Let the model craft clarifying questions when available."""
|
||||
if CLIENT_MODE != "langchain" or not missing:
|
||||
return None
|
||||
|
||||
collected_lines = [f"- {schema.get('labels', {}).get(field, [field])[0]}: {value}" for field, value in collected.items()]
|
||||
missing_labels = [schema.get("labels", {}).get(field, [field])[0] for field in missing]
|
||||
user_text = "We already have:\n" + "\n".join(collected_lines) if collected_lines else "We have nothing yet."
|
||||
user_text += "\nNeed to ask for: " + ", ".join(missing_labels)
|
||||
if not collected_lines:
|
||||
user_text += "\nInvite them to paste an old resume or dump all details if they have them."
|
||||
|
||||
system_prompt = brief_missing_info_system_prompt(doc_type)
|
||||
try:
|
||||
llm = get_chat_model(SMART_MODEL, max_tokens=400)
|
||||
if not llm:
|
||||
return None
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(
|
||||
to_lc_messages(
|
||||
[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_text},
|
||||
]
|
||||
)
|
||||
)
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[AI] missing-questions model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
SMART_MODEL,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
return response.content
|
||||
except Exception as exc:
|
||||
logger.error("[AI] missing-questions failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def gather_brief(doc_type: str, prompt: str, history: List[Dict[str, str]], current_latex: Optional[str] = None, has_pdf: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Determine whether we have enough structured details to generate without fabricating.
|
||||
Returns needsInfo + a formatted message when details are missing, or a structured brief.
|
||||
"""
|
||||
classifier = classify_intent_with_llm(prompt, history, current_latex, has_pdf)
|
||||
if classifier:
|
||||
doc_type = classifier.get("documentType", doc_type)
|
||||
logger.info(
|
||||
"[BRIEF] using llm doc_type=%s allowFabrication=%s missing=%s hasEnoughInfo=%s wantsPdf=%s",
|
||||
doc_type,
|
||||
classifier.get("allowFabrication"),
|
||||
classifier.get("missingFields"),
|
||||
classifier.get("hasEnoughInfo"),
|
||||
classifier.get("wantsPdf"),
|
||||
)
|
||||
else:
|
||||
logger.info("[BRIEF] llm classifier unavailable, using fallback schema doc_type=%s", doc_type)
|
||||
|
||||
schema = BRIEF_SCHEMAS.get(doc_type)
|
||||
if not schema:
|
||||
return {"needsInfo": False, "structured_brief": None, "collected": {}, "missing": []}
|
||||
|
||||
user_texts = [entry.get("content", "") for entry in history if entry.get("role") == "user"]
|
||||
user_texts.append(prompt or "")
|
||||
combined_text = "\n".join(user_texts)
|
||||
collected = _extract_structured_fields(combined_text, schema)
|
||||
missing = [field for field in schema.get("field_order", []) if field not in collected]
|
||||
classifier_has_enough = bool(classifier.get("hasEnoughInfo")) if classifier else True
|
||||
allow_makeup = bool(classifier.get("allowFabrication")) if classifier else False
|
||||
if not allow_makeup:
|
||||
allow_makeup = detect_fabrication_opt_in(prompt, history)
|
||||
if classifier and classifier.get("missingFields"):
|
||||
# If the model provided missing fields, respect that list.
|
||||
missing = classifier.get("missingFields") or missing
|
||||
|
||||
# If the user gave no usable content, do not allow fabrication shortcuts.
|
||||
# Force the flow to ask for the required fields instead of silently proceeding.
|
||||
missing_all_fields = len(missing) == len(schema.get("field_order", []))
|
||||
low_signal_request = not collected and len(combined_text.strip()) < 12
|
||||
if missing_all_fields and low_signal_request:
|
||||
allow_makeup = False
|
||||
|
||||
def has_minimum_resume(data: Dict[str, str]) -> bool:
|
||||
has_name = bool(data.get("name"))
|
||||
has_core = any(data.get(key) for key in ["work_history", "education", "skills", "contact", "target_role"])
|
||||
return has_name and has_core
|
||||
|
||||
def has_minimum_invoice(data: Dict[str, str]) -> bool:
|
||||
has_parties = data.get("your_business") and data.get("client")
|
||||
has_items = bool(data.get("line_items"))
|
||||
return bool(has_parties and has_items)
|
||||
|
||||
has_minimum = True
|
||||
if doc_type == "resume":
|
||||
has_minimum = has_minimum_resume(collected)
|
||||
elif doc_type == "invoice":
|
||||
has_minimum = has_minimum_invoice(collected)
|
||||
|
||||
# Decide if we must pause to ask for details. Avoid regex "ready" guesses;
|
||||
# rely on the classifier's allowFabrication flag and collected data.
|
||||
must_ask_first = bool(missing) and ((not allow_makeup and not has_minimum) or not classifier_has_enough)
|
||||
if must_ask_first:
|
||||
preface = None
|
||||
if doc_type == "resume" and not has_minimum:
|
||||
preface = (
|
||||
"I only have a tiny bit so far. I need at least your name plus one of: contact, "
|
||||
"a role snippet, education, skills, or target role."
|
||||
)
|
||||
if doc_type == "invoice" and not has_minimum:
|
||||
preface = "Need who is billing, who is paying, and the line items so I don't invent details."
|
||||
logger.info(
|
||||
"[BRIEF] gating doc_type=%s missing=%s has_minimum=%s allowFabrication=%s",
|
||||
doc_type,
|
||||
missing,
|
||||
has_minimum,
|
||||
allow_makeup,
|
||||
)
|
||||
message = _ai_missing_message(doc_type, schema, collected, missing) or _format_missing_message(
|
||||
doc_type, schema, collected, missing, preface=preface
|
||||
)
|
||||
message += "\nIf you'd like me to invent anything you didn't share, just say so."
|
||||
return {
|
||||
"needsInfo": True,
|
||||
"message": message,
|
||||
"collected": collected,
|
||||
"missing": missing,
|
||||
"allowFabrication": allow_makeup,
|
||||
}
|
||||
|
||||
# If fabrication is allowed but fields are missing, let downstream generation
|
||||
# know which areas to fill in plausibly.
|
||||
fabrication_hint = ""
|
||||
if allow_makeup and missing:
|
||||
fabrication_hint = "\n\nIf details are absent, invent plausible, clearly fictional details for: " + ", ".join(missing) + "."
|
||||
|
||||
structured_lines = []
|
||||
for field in schema.get("field_order", []):
|
||||
value = collected.get(field)
|
||||
if value:
|
||||
label = schema.get("labels", {}).get(field, [field])[0]
|
||||
structured_lines.append(f"{label}: {value}")
|
||||
structured_brief = "\n".join(structured_lines)
|
||||
if fabrication_hint:
|
||||
structured_brief = (structured_brief + fabrication_hint).strip()
|
||||
if combined_text.strip():
|
||||
structured_brief = (structured_brief + "\n\nRaw user notes:\n" + combined_text).strip()
|
||||
|
||||
return {
|
||||
"needsInfo": False,
|
||||
"structured_brief": structured_brief or None,
|
||||
"collected": collected,
|
||||
"missing": missing,
|
||||
"allowFabrication": allow_makeup,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["gather_brief", "BRIEF_SCHEMAS", "_preprocess_intent"]
|
||||
@@ -0,0 +1,94 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||
ASSETS_DIR = os.path.join(OUTPUT_DIR, "assets")
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
TEMPLATE_DIR = os.path.join(BASE_DIR, "templates")
|
||||
STYLE_DB_PATH = os.path.join(DATA_DIR, "user_styles.json")
|
||||
TEMPLATE_DB_PATH = os.path.join(DATA_DIR, "user_templates.json")
|
||||
VERSIONS_DB_PATH = os.path.join(DATA_DIR, "versions.json")
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
os.makedirs(ASSETS_DIR, exist_ok=True)
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(TEMPLATE_DIR, exist_ok=True)
|
||||
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
|
||||
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL")
|
||||
JAVA_BACKEND_URL = os.environ.get("JAVA_BACKEND_URL", "http://localhost:8080")
|
||||
|
||||
if not OPENAI_API_KEY:
|
||||
raise RuntimeError("OPENAI_API_KEY is required to start the AI backend.")
|
||||
# Default to GPT-5.1 for full document generation (smart model).
|
||||
# Allow override via SMART_MODEL or legacy OPENAI_MODEL.
|
||||
SMART_MODEL = os.environ.get("SMART_MODEL") or os.environ.get("OPENAI_MODEL") or "gpt-5.1"
|
||||
# Default to the nano/ultra-fast tier for intent/pre checks (fast model).
|
||||
# Allow override via FAST_MODEL or legacy FAST_INTENT_MODEL.
|
||||
FAST_MODEL = os.environ.get("FAST_MODEL") or os.environ.get("FAST_INTENT_MODEL") or "gpt-4.1-nano"
|
||||
|
||||
CLIENT_MODE: Optional[str] = None
|
||||
LANGCHAIN_AVAILABLE = False
|
||||
_ChatOpenAI = None
|
||||
STREAMING_ENABLED = os.environ.get("AI_STREAMING", "true").lower() not in {"0", "false", "no"}
|
||||
if OPENAI_BASE_URL and "ollama" in OPENAI_BASE_URL and "AI_STREAMING" not in os.environ:
|
||||
STREAMING_ENABLED = False
|
||||
PREVIEW_MAX_INFLIGHT = int(os.environ.get("AI_PREVIEW_MAX_INFLIGHT", "3"))
|
||||
|
||||
if OPENAI_API_KEY:
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI # type: ignore
|
||||
|
||||
_ChatOpenAI = ChatOpenAI
|
||||
LANGCHAIN_AVAILABLE = True
|
||||
CLIENT_MODE = "langchain"
|
||||
except Exception as client_exc: # pragma: no cover - import guard
|
||||
logger.warning("LangChain OpenAI init failed: %s", client_exc)
|
||||
|
||||
if CLIENT_MODE == "langchain":
|
||||
logger.info("AI mode: LIVE (fast_model=%s smart_model=%s)", FAST_MODEL, SMART_MODEL)
|
||||
else:
|
||||
logger.info("AI mode: MOCK (no OpenAI key or LangChain init failure)")
|
||||
|
||||
|
||||
def get_chat_model(
|
||||
model_name: str,
|
||||
streaming: bool = False,
|
||||
max_tokens: Optional[int] = None,
|
||||
model_kwargs: Optional[dict] = None,
|
||||
):
|
||||
if not LANGCHAIN_AVAILABLE or not _ChatOpenAI:
|
||||
return None
|
||||
kwargs = {"model": model_name, "api_key": OPENAI_API_KEY, "streaming": streaming}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
if model_kwargs:
|
||||
kwargs["model_kwargs"] = model_kwargs
|
||||
return _ChatOpenAI(**kwargs)
|
||||
|
||||
__all__ = [
|
||||
"logger",
|
||||
"OUTPUT_DIR",
|
||||
"ASSETS_DIR",
|
||||
"DATA_DIR",
|
||||
"TEMPLATE_DIR",
|
||||
"STYLE_DB_PATH",
|
||||
"TEMPLATE_DB_PATH",
|
||||
"VERSIONS_DB_PATH",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"JAVA_BACKEND_URL",
|
||||
"SMART_MODEL",
|
||||
"CLIENT_MODE",
|
||||
"LANGCHAIN_AVAILABLE",
|
||||
"get_chat_model",
|
||||
"FAST_MODEL",
|
||||
"STREAMING_ENABLED",
|
||||
"PREVIEW_MAX_INFLIGHT",
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
KEYWORDS: List[Tuple[str, str]] = [
|
||||
("business card", "business_card"),
|
||||
("business-card", "business_card"),
|
||||
("card", "business_card"),
|
||||
("recipe", "recipe"),
|
||||
("cookbook", "recipe"),
|
||||
("menu", "menu"),
|
||||
("flyer", "flyer"),
|
||||
("brochure", "brochure"),
|
||||
("poster", "poster"),
|
||||
("slide", "presentation"),
|
||||
("deck", "presentation"),
|
||||
("presentation", "presentation"),
|
||||
("pitch", "presentation"),
|
||||
("whitepaper", "whitepaper"),
|
||||
("datasheet", "datasheet"),
|
||||
("case study", "case_study"),
|
||||
("press release", "press_release"),
|
||||
("agenda", "agenda"),
|
||||
("minutes", "minutes"),
|
||||
("checklist", "checklist"),
|
||||
("newsletter", "newsletter"),
|
||||
("proposal", "proposal"),
|
||||
("one-pager", "one_pager"),
|
||||
("one pager", "one_pager"),
|
||||
("invoice", "invoice"),
|
||||
("resume", "resume"),
|
||||
("cv", "resume"),
|
||||
("contract", "contract"),
|
||||
("agreement", "contract"),
|
||||
("letter", "letter"),
|
||||
("report", "report"),
|
||||
("paper", "academic"),
|
||||
("research", "academic"),
|
||||
("thesis", "academic"),
|
||||
("poem", "creative"),
|
||||
("manual", "manual"),
|
||||
("timeline", "timeline"),
|
||||
]
|
||||
|
||||
|
||||
def detect_document_type(prompt: str, latex_code: str | None = None) -> str:
|
||||
"""Heuristic classifier for document types based on prompt/latex text."""
|
||||
text = (prompt or "").lower()
|
||||
latex_text = (latex_code or "").lower()
|
||||
for keyword, label in KEYWORDS:
|
||||
if keyword in text or keyword in latex_text:
|
||||
return label
|
||||
return "document"
|
||||
|
||||
|
||||
__all__ = ["detect_document_type"]
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
|
||||
def to_lc_messages(messages: List[Dict[str, Any]]):
|
||||
lc_messages = []
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
if role == "system":
|
||||
lc_messages.append(SystemMessage(content=content))
|
||||
elif role == "assistant":
|
||||
lc_messages.append(AIMessage(content=content))
|
||||
else:
|
||||
lc_messages.append(HumanMessage(content=content))
|
||||
return lc_messages
|
||||
|
||||
|
||||
__all__ = ["to_lc_messages"]
|
||||
@@ -0,0 +1,547 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
|
||||
ALLOWED_LATEX_PACKAGES = {
|
||||
"courier",
|
||||
"graphicx",
|
||||
"geometry",
|
||||
"helvet",
|
||||
"lmodern",
|
||||
"mathpazo",
|
||||
"xcolor",
|
||||
"tabularx",
|
||||
"paracol",
|
||||
"multicol",
|
||||
"longtable",
|
||||
"setspace",
|
||||
"enumitem",
|
||||
"titlesec",
|
||||
"array",
|
||||
"inputenc",
|
||||
"fontenc",
|
||||
"tikz",
|
||||
}
|
||||
|
||||
|
||||
def _strip_body_content(body: str) -> str:
|
||||
"""
|
||||
Remove user data while keeping layout/structure commands.
|
||||
Keeps \begin/\end blocks, command scaffolding, and drops plain text.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
lines.append("")
|
||||
continue
|
||||
if stripped.startswith("%"):
|
||||
continue
|
||||
if "\\begin" in stripped or "\\end" in stripped:
|
||||
lines.append(line)
|
||||
continue
|
||||
if stripped.startswith("\\"):
|
||||
line_no_comments = line.split("%", 1)[0]
|
||||
line_sections = re.sub(
|
||||
r"(\\(?:section|subsection|subsubsection|paragraph|subparagraph|chapter|part)\*?)\{[^}]*\}",
|
||||
r"\\1{}",
|
||||
line_no_comments,
|
||||
)
|
||||
line_items = re.sub(r"^\\item.*", r"\\item {}", line_sections)
|
||||
line_text_cmds = re.sub(
|
||||
r"\\text(?:bf|it|tt|sc|sf|normal|emph)\{[^}]*\}",
|
||||
lambda match: match.group(0).split("{")[0] + "{}",
|
||||
line_items,
|
||||
)
|
||||
cleaned = re.sub(r"(?<!\\)[A-Za-z][A-Za-z0-9 ,.;:'\"!?-]*", "", line_text_cmds).strip()
|
||||
if cleaned:
|
||||
lines.append(cleaned)
|
||||
continue
|
||||
cleaned = re.sub(r"[A-Za-z0-9]+", "", line).strip()
|
||||
if cleaned:
|
||||
lines.append(cleaned)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def extract_layout_hint(latex_code: str, max_chars: Optional[int] = None) -> str:
|
||||
"""Keep layout-defining LaTeX while stripping user text."""
|
||||
if not latex_code:
|
||||
return ""
|
||||
|
||||
preamble, body = "", latex_code
|
||||
split_doc = latex_code.split(r"\begin{document}", 1)
|
||||
if len(split_doc) == 2:
|
||||
preamble, body = split_doc
|
||||
sanitized_body = _strip_body_content(body)
|
||||
|
||||
hint = f"{preamble}\n% --- layout only (data stripped) ---\n{sanitized_body}"
|
||||
return hint if max_chars is None else hint[:max_chars]
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _word_to_int(word: str) -> Optional[int]:
|
||||
"""Convert simple English number words to int (0-100)."""
|
||||
words = {
|
||||
"zero": 0,
|
||||
"one": 1,
|
||||
"two": 2,
|
||||
"three": 3,
|
||||
"four": 4,
|
||||
"five": 5,
|
||||
"six": 6,
|
||||
"seven": 7,
|
||||
"eight": 8,
|
||||
"nine": 9,
|
||||
"ten": 10,
|
||||
"twenty": 20,
|
||||
"thirty": 30,
|
||||
"forty": 40,
|
||||
"fifty": 50,
|
||||
"sixty": 60,
|
||||
"seventy": 70,
|
||||
"eighty": 80,
|
||||
"ninety": 90,
|
||||
"hundred": 100,
|
||||
}
|
||||
return words.get(word.strip().lower())
|
||||
|
||||
|
||||
def _sanitize_color_mix(match: re.Match[str]) -> str:
|
||||
token = match.group(1)
|
||||
if token.isdigit():
|
||||
val = int(token)
|
||||
else:
|
||||
converted = _word_to_int(token)
|
||||
if converted is None:
|
||||
digits = "".join(ch for ch in token if ch.isdigit())
|
||||
val = int(digits) if digits else 80
|
||||
else:
|
||||
val = converted
|
||||
val = max(0, min(100, val))
|
||||
return f"!{val}!"
|
||||
|
||||
|
||||
def sanitize_latex(latex_code: str) -> str:
|
||||
"""Normalize invalid xcolor syntax like '!eighty!' to '!80!'."""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
return re.sub(r"!\s*([A-Za-z0-9]+)\s*!", _sanitize_color_mix, latex_code)
|
||||
|
||||
|
||||
def strip_missing_packages(latex_code: str) -> str:
|
||||
"""Remove packages unavailable in the runtime environment."""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
code = latex_code
|
||||
code = re.sub(r"^\\usepackage\{siunitx\}\s*$", "", code, flags=re.MULTILINE)
|
||||
code = re.sub(r"\\sisetup\{[^}]*\}", "", code, flags=re.DOTALL)
|
||||
code = re.sub(r"\\num\{([^}]*)\}", r"\\1", code)
|
||||
code = re.sub(
|
||||
r"^\\(setmainfont|setsansfont|setmonofont|newfontfamily)\b.*$",
|
||||
"",
|
||||
code,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
def _filter_packages(match: re.Match[str]) -> str:
|
||||
options = match.group(1) or ""
|
||||
packages = [pkg.strip() for pkg in match.group(2).split(",") if pkg.strip()]
|
||||
allowed = [pkg for pkg in packages if pkg in ALLOWED_LATEX_PACKAGES]
|
||||
if not allowed:
|
||||
return ""
|
||||
return f"\\usepackage{options}{{{', '.join(allowed)}}}"
|
||||
|
||||
return re.sub(
|
||||
r"^\\usepackage(\[[^\]]*\])?\{([^}]*)\}\s*$",
|
||||
_filter_packages,
|
||||
code,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def remove_leading_pagebreaks(latex_code: str) -> str:
|
||||
"""Strip explicit page breaks at the start of the document body."""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
parts = latex_code.split(r"\begin{document}", 1)
|
||||
if len(parts) == 2:
|
||||
preamble, body = parts
|
||||
cleaned_body = re.sub(
|
||||
r"^\s*(\\(newpage|clearpage|pagebreak|vfill)\b\s*)+",
|
||||
"\n",
|
||||
body,
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
return f"{preamble}\\begin{{document}}{cleaned_body}"
|
||||
return re.sub(
|
||||
r"^\s*(\\(newpage|clearpage|pagebreak|vfill)\b\s*)+",
|
||||
"\n",
|
||||
latex_code,
|
||||
flags=re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def strip_leading_pagebreaks(latex_code: str) -> str:
|
||||
"""Drop accidental leading page breaks that cause empty first pages."""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
parts = latex_code.split(r"\begin{document}", 1)
|
||||
if len(parts) == 2:
|
||||
preamble, body = parts
|
||||
cleaned_body = re.sub(
|
||||
r"^\s*(\\clearpage|\\newpage|\\pagebreak|\\vfill)+\s*",
|
||||
"",
|
||||
body,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
return f"{preamble}\\begin{{document}}{cleaned_body}"
|
||||
return re.sub(
|
||||
r"^\s*(\\clearpage|\\newpage|\\pagebreak|\\vfill)+\s*",
|
||||
"",
|
||||
latex_code,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def fix_tabular_row_endings(latex_code: str) -> str:
|
||||
"""Ensure tabular environments close rows before \\end{tabular}."""
|
||||
pattern = re.compile(r"(&[^\n]*)\n\\end{tabular}", re.MULTILINE)
|
||||
return pattern.sub(r"\\1 \\\\ \n\\end{tabular}", latex_code)
|
||||
|
||||
def strip_placeholder_rules(latex_code: str) -> str:
|
||||
"""Remove placeholder boxes like \\rule/\\colorbox used as fake images."""
|
||||
code = re.sub(r"\\rule\s*\{\s*[\d\.]+[a-zA-Z]*\s*\}\s*\{\s*[\d\.]+[a-zA-Z]*\s*\}", "", latex_code)
|
||||
code = re.sub(r"\\fcolorbox\{[^}]*\}\{[^}]*\}\{[^}]*\}", "", code)
|
||||
code = re.sub(r"\\colorbox\{[^}]*\}\{[^}]*\}", "", code)
|
||||
# Strip simple tikz pictures that are just boxes/fills
|
||||
code = re.sub(
|
||||
r"\\begin\{tikzpicture\}[\s\S]*?\\end\{tikzpicture\}",
|
||||
"",
|
||||
code,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
return code
|
||||
|
||||
|
||||
def strip_number_grouping_junk(latex_code: str) -> str:
|
||||
"""Remove stray siunitx options text that may leak into the document body."""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
# Drop standalone lines/paragraphs that look like siunitx option lists (common when chunks split)
|
||||
return re.sub(
|
||||
r"(?im)^\s*,\s*(group-minimum-digits|detect-all|table-number-alignment|round-mode|round-precision)\b.*$",
|
||||
"",
|
||||
latex_code,
|
||||
)
|
||||
|
||||
|
||||
def rebalance_invoice_tables(latex_code: str) -> str:
|
||||
"""Use wrapped columns for common invoice tables to avoid overflow."""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
|
||||
# Legacy 4-col invoices (Item, Desc, Price, Total)
|
||||
code = latex_code.replace(
|
||||
r"\\begin{tabularx}{\\textwidth}{@{}l l r r@{}}",
|
||||
r"\\begin{tabularx}{\\textwidth}{@{}>{\\raggedright\\arraybackslash}p{0.30\\textwidth}>{\\raggedright\\arraybackslash}X>{\\raggedleft\\arraybackslash}p{1.5cm}>{\\raggedleft\\arraybackslash}p{2.3cm}@{}}",
|
||||
)
|
||||
|
||||
# Current 5-col invoices (Item, Description, Qty, Unit, Line Total)
|
||||
wrapped_invoice_five = (
|
||||
r"@{}"
|
||||
r">{\\raggedright\\arraybackslash}p{0.16\\textwidth}"
|
||||
r">{\\raggedright\\arraybackslash}p{0.50\\textwidth}"
|
||||
r">{\\raggedleft\\arraybackslash}p{0.09\\textwidth}"
|
||||
r">{\\raggedleft\\arraybackslash}p{0.12\\textwidth}"
|
||||
r">{\\raggedleft\\arraybackslash}p{0.13\\textwidth}"
|
||||
r"@{}"
|
||||
)
|
||||
|
||||
five_col_patterns = [
|
||||
(
|
||||
# tabularx with first col l/c, X desc, then three p{} numeric cols (matches default invoice template)
|
||||
r"(\\begin{tabularx}\{\s*\\textwidth\s*\}\{)\s*@?\{\}?\s*[cl]\s+X\s+p\{[^}]+\}\s+p\{[^}]+\}\s+p\{[^}]+\}\s*@?\{\}?\s*(\})",
|
||||
r"\1" + wrapped_invoice_five + r"\2",
|
||||
),
|
||||
(
|
||||
# longtable version of the same layout (after upgrades)
|
||||
r"(\\begin{longtable}\{)\s*@?\{\}?\s*[cl]\s+X\s+p\{[^}]+\}\s+p\{[^}]+\}\s+p\{[^}]+\}\s*@?\{\}?\s*(\})",
|
||||
r"\1" + wrapped_invoice_five + r"\2",
|
||||
),
|
||||
]
|
||||
|
||||
for pattern, replacement in five_col_patterns:
|
||||
code = re.sub(pattern, replacement, code, flags=re.IGNORECASE)
|
||||
|
||||
return code
|
||||
|
||||
|
||||
def normalize_tabular_like_begins(latex_code: str) -> str:
|
||||
"""
|
||||
Fix common malformed tabular/tabularx/longtable begins where the colspec
|
||||
is not passed as a braced argument (e.g. `\\begin{tabularx}\\textwidth{...}`
|
||||
or `\\begin{tabularx}{\\textwidth}\\ItemsColSpec`).
|
||||
"""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
|
||||
code = latex_code
|
||||
|
||||
# \begin{tabularx}\textwidth{...} -> \begin{tabularx}{\textwidth}{...}
|
||||
code = re.sub(
|
||||
r"\\begin{tabularx}\s*\\textwidth\s*\{([^}]*)\}",
|
||||
lambda m: f"\\begin{{tabularx}}{{\\textwidth}}{{{m.group(1).strip()}}}",
|
||||
code,
|
||||
)
|
||||
|
||||
# \begin{tabularx}{\textwidth}\ItemsColSpec -> wrap colspec in braces
|
||||
code = re.sub(
|
||||
r"\\begin{tabularx}\s*\{\s*\\textwidth\s*\}\s*\\([A-Za-z@][\w@]*)",
|
||||
lambda m: f"\\begin{{tabularx}}{{\\textwidth}}{{\\{m.group(1)}}}",
|
||||
code,
|
||||
)
|
||||
|
||||
# \begin{tabularx}{\textwidth}\colspecliteral -> wrap literal spec
|
||||
code = re.sub(
|
||||
r"\\begin{tabularx}\s*\{\s*\\textwidth\s*\}\s*([@A-Za-z].*)",
|
||||
lambda m: f"\\begin{{tabularx}}{{\\textwidth}}{{{m.group(1).strip()}}}",
|
||||
code,
|
||||
)
|
||||
|
||||
# \begin{tabular}\colspecliteral OR \begin{longtable}\colspecliteral
|
||||
def _wrap_simple(env: str, text: str) -> str:
|
||||
return re.sub(
|
||||
rf"\\begin{{{env}}}\s*([@A-Za-z].*)",
|
||||
lambda m: f"\\begin{{{env}}}{{{m.group(1).strip()}}}",
|
||||
text,
|
||||
)
|
||||
|
||||
code = _wrap_simple("tabular", code)
|
||||
code = _wrap_simple("longtable", code)
|
||||
return code
|
||||
|
||||
|
||||
def ensure_longtable_support(latex_code: str) -> str:
|
||||
"""
|
||||
Guarantee longtable availability and default centering.
|
||||
|
||||
- Injects \\usepackage{longtable} if missing.
|
||||
- Sets \\LTleft/\\LTright to 0pt so longtable spans the text width without manual centering.
|
||||
"""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
|
||||
code = latex_code
|
||||
if r"\usepackage{longtable}" not in code:
|
||||
code = re.sub(
|
||||
r"(\\documentclass[^\n]*\n)",
|
||||
r"\1\\usepackage{longtable}\n",
|
||||
code,
|
||||
count=1,
|
||||
)
|
||||
if r"\setlength\LTleft" not in code:
|
||||
# Use a lambda so backslashes are treated literally (avoid \L escape errors).
|
||||
code = re.sub(
|
||||
r"(\\usepackage\{longtable\}[^\n]*\n)",
|
||||
lambda m: f"{m.group(1)}\\setlength\\LTleft{{0pt}}\n\\setlength\\LTright{{0pt}}\n",
|
||||
code,
|
||||
count=1,
|
||||
)
|
||||
return code
|
||||
|
||||
|
||||
def _normalize_alignment_to_wrapped_columns(spec: str) -> str:
|
||||
"""
|
||||
Convert simple l/c/r specs to wrapped p-columns that respect text width.
|
||||
Keeps existing p/m/b/X columns unchanged.
|
||||
"""
|
||||
if re.search(r"[pmb]\{|\bX\b", spec):
|
||||
return spec
|
||||
|
||||
cols = [ch for ch in spec if ch in ("l", "c", "r")]
|
||||
if not cols:
|
||||
return spec
|
||||
|
||||
width = max(0.05, min(0.98, 0.98 / len(cols)))
|
||||
parts: List[str] = []
|
||||
for ch in cols:
|
||||
if ch == "r":
|
||||
parts.append(r">{\raggedleft\arraybackslash}p{" + f"{width:.3f}\\textwidth" + "}")
|
||||
else:
|
||||
parts.append(r">{\raggedright\arraybackslash}p{" + f"{width:.3f}\\textwidth" + "}")
|
||||
return "@{}" + "".join(parts) + "@{}"
|
||||
|
||||
|
||||
def upgrade_tabular_tables_to_longtable(latex_code: str) -> str:
|
||||
"""
|
||||
Replace table+tabular blocks with longtable so large tables break across pages,
|
||||
stay centered, and repeat headers on each page.
|
||||
"""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
|
||||
pattern = re.compile(
|
||||
r"\\begin{table}.*?\\begin{tabular}\{([^}]*)\}(.*?)\\end{tabular}.*?\\end{table}",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def _build_longtable(match: re.Match[str]) -> str:
|
||||
align_spec = match.group(1)
|
||||
body = match.group(2).strip()
|
||||
normalized_spec = _normalize_alignment_to_wrapped_columns(align_spec)
|
||||
|
||||
header_block = ""
|
||||
body_block = body
|
||||
|
||||
hline_split = re.split(r"\\hline", body, maxsplit=1)
|
||||
if len(hline_split) == 2:
|
||||
header_block = hline_split[0].strip() + r"\\\hline"
|
||||
body_block = hline_split[1].lstrip()
|
||||
else:
|
||||
first_row_split = re.split(r"\\\\", body, maxsplit=1)
|
||||
header_block = (first_row_split[0].strip() + r"\\") if first_row_split else ""
|
||||
body_block = first_row_split[1].lstrip() if len(first_row_split) == 2 else body
|
||||
|
||||
header_block = header_block.strip()
|
||||
body_block = body_block.strip()
|
||||
|
||||
return (
|
||||
"\n\\setlength\\LTleft{0pt}\n"
|
||||
"\\setlength\\LTright{0pt}\n"
|
||||
f"\\begin{{longtable}}{{{normalized_spec}}}\n"
|
||||
f"{header_block}\n"
|
||||
"\\endfirsthead\n"
|
||||
f"{header_block}\n"
|
||||
"\\endhead\n"
|
||||
f"{body_block}\n"
|
||||
"\\end{longtable}\n"
|
||||
)
|
||||
|
||||
return pattern.sub(_build_longtable, latex_code)
|
||||
|
||||
|
||||
def clean_generated_latex(latex_code: str) -> str:
|
||||
"""Apply all sanitizers used both for compilation and template storage."""
|
||||
return rebalance_invoice_tables(
|
||||
upgrade_tabular_tables_to_longtable(
|
||||
ensure_longtable_support(
|
||||
normalize_tabular_like_begins(
|
||||
strip_number_grouping_junk(
|
||||
fix_tabular_row_endings(
|
||||
strip_leading_pagebreaks(
|
||||
remove_leading_pagebreaks(
|
||||
strip_missing_packages(
|
||||
sanitize_latex(
|
||||
ensure_full_latex_document(latex_code)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def apply_style_overrides(latex_code: str, style_profile: dict) -> str:
|
||||
"""Apply deterministic font + accent styling without altering layout."""
|
||||
if not latex_code:
|
||||
return latex_code
|
||||
|
||||
code = latex_code
|
||||
font = (style_profile or {}).get("font_preference") or ""
|
||||
accent = (style_profile or {}).get("color_accent") or ""
|
||||
|
||||
font_map = {
|
||||
"serif": ("mathpazo", "\\renewcommand{\\familydefault}{\\rmdefault}"),
|
||||
"sans": ("helvet", "\\renewcommand{\\familydefault}{\\sfdefault}"),
|
||||
"helvet": ("helvet", "\\renewcommand{\\familydefault}{\\sfdefault}"),
|
||||
"mono": ("courier", "\\renewcommand{\\familydefault}{\\ttdefault}"),
|
||||
"modern": ("lmodern", None),
|
||||
}
|
||||
pkg = None
|
||||
family_cmd = None
|
||||
if isinstance(font, str):
|
||||
pkg, family_cmd = font_map.get(font.lower(), (None, None))
|
||||
|
||||
if pkg:
|
||||
code = re.sub(
|
||||
r"^\\usepackage\{(helvet|mathpazo|lmodern|courier)\}\s*$",
|
||||
"",
|
||||
code,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
code = re.sub(
|
||||
r"^\\renewcommand\{\\familydefault\}\{\\(sfdefault|rmdefault|ttdefault)\}\s*$",
|
||||
"",
|
||||
code,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
def _ensure_package_and_command(text: str) -> str:
|
||||
if not pkg:
|
||||
return text
|
||||
insert = f"\\usepackage{{{pkg}}}\n"
|
||||
if family_cmd:
|
||||
insert += family_cmd + "\n"
|
||||
if r"\begin{document}" in text:
|
||||
return re.sub(r"(\\begin\{document\})", insert + r"\1", text, count=1)
|
||||
return insert + text
|
||||
|
||||
code = _ensure_package_and_command(code)
|
||||
|
||||
if accent:
|
||||
accent_hex_match = re.fullmatch(r"#?([0-9a-fA-F]{6})", str(accent).strip())
|
||||
if accent_hex_match:
|
||||
accent_line = f"\\definecolor{{accent}}{{HTML}}{{{accent_hex_match.group(1).upper()}}}"
|
||||
else:
|
||||
accent_name = re.sub(r"[^A-Za-z]+", "", str(accent)) or "blue"
|
||||
accent_line = f"\\colorlet{{accent}}{{{accent_name}}}"
|
||||
|
||||
if re.search(r"^\\definecolor\{accent\}|^\\colorlet\{accent\}", code, flags=re.MULTILINE):
|
||||
code = re.sub(r"^\\definecolor\{accent\}.*$", accent_line, code, flags=re.MULTILINE)
|
||||
code = re.sub(r"^\\colorlet\{accent\}.*$", accent_line, code, flags=re.MULTILINE)
|
||||
else:
|
||||
needs_xcolor = r"\usepackage{xcolor}" not in code
|
||||
insert = ""
|
||||
if needs_xcolor:
|
||||
insert += "\\usepackage{xcolor}\n"
|
||||
insert += accent_line + "\n"
|
||||
if r"\usepackage{xcolor}" in code:
|
||||
code = re.sub(r"(\\usepackage\{xcolor\}[^\n]*\n)", r"\1" + insert, code, count=1)
|
||||
elif r"\begin{document}" in code:
|
||||
code = re.sub(r"(\\begin\{document\})", insert + r"\1", code, count=1)
|
||||
else:
|
||||
code = insert + code
|
||||
|
||||
return code
|
||||
|
||||
|
||||
def ensure_full_latex_document(text: str) -> str:
|
||||
"""Trim output to a single LaTeX document starting at \\documentclass and ending at \\end{document}."""
|
||||
if not text:
|
||||
return text
|
||||
start = text.find(r"\documentclass")
|
||||
end = text.rfind(r"\end{document}")
|
||||
if start == -1 or end == -1:
|
||||
return text
|
||||
end += len(r"\end{document}")
|
||||
return text[start:end]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"extract_layout_hint",
|
||||
"sanitize_latex",
|
||||
"strip_missing_packages",
|
||||
"remove_leading_pagebreaks",
|
||||
"strip_leading_pagebreaks",
|
||||
"fix_tabular_row_endings",
|
||||
"rebalance_invoice_tables",
|
||||
"clean_generated_latex",
|
||||
"apply_style_overrides",
|
||||
"ensure_full_latex_document",
|
||||
]
|
||||
@@ -0,0 +1,491 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import logger
|
||||
|
||||
|
||||
def _safe_float(value: Optional[str], fallback: float = 0.0) -> float:
|
||||
"""Convert an attribute value to float while handling bad input."""
|
||||
try:
|
||||
if value is None:
|
||||
return fallback
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def _read_image_as_data_url(path: str) -> Optional[str]:
|
||||
"""Return a data URL for the image if it exists."""
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
mime = "image/png"
|
||||
_, ext = os.path.splitext(path)
|
||||
if ext.lower() in {".jpg", ".jpeg"}:
|
||||
mime = "image/jpeg"
|
||||
elif ext.lower() == ".gif":
|
||||
mime = "image/gif"
|
||||
try:
|
||||
with open(path, "rb") as img_handle:
|
||||
encoded = base64.b64encode(img_handle.read()).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
except OSError as exc:
|
||||
logger.warning("[PDF-EDITOR] Failed to read image %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_fonts(root: ET.Element) -> List[Dict[str, Any]]:
|
||||
fonts: List[Dict[str, Any]] = []
|
||||
for spec in root.findall(".//fontspec"):
|
||||
font_id = spec.attrib.get("id")
|
||||
base_name = spec.attrib.get("family")
|
||||
size = _safe_float(spec.attrib.get("size"), 12.0)
|
||||
color = spec.attrib.get("color")
|
||||
name_lower = (base_name or "").lower()
|
||||
flags = 0
|
||||
if "bold" in name_lower:
|
||||
flags |= 0x100 # ForceBold
|
||||
if "italic" in name_lower or "oblique" in name_lower:
|
||||
flags |= 0x40 # Italic
|
||||
fonts.append(
|
||||
{
|
||||
"id": font_id,
|
||||
"uid": font_id,
|
||||
"baseName": base_name,
|
||||
"embedded": True,
|
||||
"program": None,
|
||||
"programFormat": None,
|
||||
"webProgram": None,
|
||||
"webProgramFormat": None,
|
||||
"pdfProgram": None,
|
||||
"pdfProgramFormat": None,
|
||||
"ascent": size,
|
||||
"descent": -size * 0.25,
|
||||
"unitsPerEm": max(size, 1),
|
||||
"standard14Name": None,
|
||||
"color": color,
|
||||
"fontDescriptorFlags": flags or None,
|
||||
}
|
||||
)
|
||||
return fonts
|
||||
|
||||
|
||||
def _parse_color_components(color: Optional[str]) -> Optional[List[float]]:
|
||||
"""Convert a hex/rgb color string into normalized RGB components."""
|
||||
if not color:
|
||||
return None
|
||||
|
||||
color = color.strip()
|
||||
hex_match = re.fullmatch(r"#?([0-9a-fA-F]{6})", color)
|
||||
short_hex_match = re.fullmatch(r"#?([0-9a-fA-F]{3})", color)
|
||||
rgb_match = re.fullmatch(r"rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)", color, re.IGNORECASE)
|
||||
|
||||
if hex_match:
|
||||
hex_value = hex_match.group(1)
|
||||
r = int(hex_value[0:2], 16)
|
||||
g = int(hex_value[2:4], 16)
|
||||
b = int(hex_value[4:6], 16)
|
||||
return [r / 255.0, g / 255.0, b / 255.0]
|
||||
|
||||
if short_hex_match:
|
||||
hex_value = short_hex_match.group(1)
|
||||
r = int(hex_value[0] * 2, 16)
|
||||
g = int(hex_value[1] * 2, 16)
|
||||
b = int(hex_value[2] * 2, 16)
|
||||
return [r / 255.0, g / 255.0, b / 255.0]
|
||||
|
||||
if rgb_match:
|
||||
r = min(max(int(rgb_match.group(1)), 0), 255)
|
||||
g = min(max(int(rgb_match.group(2)), 0), 255)
|
||||
b = min(max(int(rgb_match.group(3)), 0), 255)
|
||||
return [r / 255.0, g / 255.0, b / 255.0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# --------------------
|
||||
# Table normalization
|
||||
# --------------------
|
||||
|
||||
def _cluster(values: List[float], tol: float) -> List[List[float]]:
|
||||
clusters: List[List[float]] = []
|
||||
for v in sorted(values):
|
||||
if not clusters or abs(v - clusters[-1][-1]) > tol:
|
||||
clusters.append([v])
|
||||
else:
|
||||
clusters[-1].append(v)
|
||||
return clusters
|
||||
|
||||
def _dedupe_by_xy_text(elements: List[Dict[str, Any]], eps: float = 1.0) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Dedupe using quantized x,y and normalized text (ignores width/height jitter).
|
||||
Keeps the better scoring text; if equal text, keeps the first.
|
||||
"""
|
||||
if not elements:
|
||||
return elements
|
||||
|
||||
def _key(el: Dict[str, Any]) -> tuple[int, int, str]:
|
||||
x = el.get("x") or 0.0
|
||||
y = el.get("y") or 0.0
|
||||
t = (el.get("text") or "").strip().replace("\u00a0", " ")
|
||||
return (int(round(x / eps)), int(round(y / eps)), t)
|
||||
|
||||
def _score_text_global(t: str) -> tuple[int, int, int]:
|
||||
stripped = t.strip()
|
||||
has_currency = 1 if any(sym in stripped for sym in ("$", "€", "£", "¥")) else 0
|
||||
non_space = sum(1 for ch in stripped if not ch.isspace())
|
||||
digits = sum(1 for ch in stripped if ch.isdigit())
|
||||
return (has_currency, non_space, digits)
|
||||
|
||||
deduped: Dict[tuple[int, int, str], Dict[str, Any]] = {}
|
||||
for el in elements:
|
||||
key = _key(el)
|
||||
existing = deduped.get(key)
|
||||
if existing is None:
|
||||
deduped[key] = el
|
||||
continue
|
||||
t_new = key[2]
|
||||
t_old = (existing.get("text") or "").strip().replace("\u00a0", " ")
|
||||
score_new = _score_text_global(t_new)
|
||||
score_old = _score_text_global(t_old)
|
||||
if score_new > score_old:
|
||||
deduped[key] = el
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
def _detect_table_region(text_elements: List[Dict[str, Any]], page_width: float) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Header-agnostic table detection via x clustering.
|
||||
Returns dict with anchors, boundaries, observed_left/right, y_min/y_max.
|
||||
"""
|
||||
candidates = [
|
||||
el
|
||||
for el in text_elements
|
||||
if el.get("text") not in (None, "")
|
||||
and isinstance(el.get("x"), (int, float))
|
||||
and isinstance(el.get("y"), (int, float))
|
||||
and isinstance(el.get("height"), (int, float))
|
||||
]
|
||||
if len(candidates) < 8:
|
||||
return None
|
||||
|
||||
heights = [c["height"] for c in candidates if c.get("height")]
|
||||
if not heights:
|
||||
return None
|
||||
med_h = sorted(heights)[len(heights) // 2]
|
||||
short_candidates = [c for c in candidates if c["height"] <= med_h * 1.8]
|
||||
if len(short_candidates) < 8:
|
||||
return None
|
||||
|
||||
x_centers = [c["x"] + (c.get("width") or 0) * 0.5 for c in short_candidates]
|
||||
x_clusters = _cluster(x_centers, tol=12.0)
|
||||
x_clusters = [c for c in x_clusters if len(c) >= 3]
|
||||
if len(x_clusters) < 4:
|
||||
return None
|
||||
|
||||
anchors = [sum(c) / len(c) for c in x_clusters]
|
||||
anchors.sort()
|
||||
|
||||
y_vals = sorted(c["y"] for c in short_candidates)
|
||||
y_clusters = _cluster(y_vals, tol=3.0)
|
||||
if len(y_clusters) < 3:
|
||||
return None
|
||||
y_clusters.sort(key=lambda c: len(c), reverse=True)
|
||||
y_min = min(y_clusters[0])
|
||||
y_max = max(y_clusters[0])
|
||||
|
||||
min_anchor = min(anchors)
|
||||
max_anchor = max(anchors)
|
||||
PAD = 12.0
|
||||
|
||||
band_elems = []
|
||||
for el in short_candidates:
|
||||
cx = el["x"] + (el.get("width") or 0) * 0.5
|
||||
if y_min - PAD <= el["y"] <= y_max + PAD and (min_anchor - PAD) <= cx <= (max_anchor + PAD):
|
||||
band_elems.append(el)
|
||||
if not band_elems:
|
||||
return None
|
||||
observed_left = min(n["x"] for n in band_elems)
|
||||
observed_right = max(n["x"] + (n.get("width") or 0) for n in band_elems)
|
||||
|
||||
raw_bounds: List[float] = []
|
||||
for i, ax in enumerate(anchors):
|
||||
if i == 0:
|
||||
gap = anchors[1] - anchors[0]
|
||||
raw_bounds.append(ax - gap * 0.5)
|
||||
else:
|
||||
raw_bounds.append((anchors[i - 1] + ax) * 0.5)
|
||||
gap_last = anchors[-1] - anchors[-2] if len(anchors) > 1 else 40.0
|
||||
raw_bounds.append(anchors[-1] + gap_last * 0.5)
|
||||
|
||||
# translate boundaries to align left edge; no scaling to preserve spacing
|
||||
delta = observed_left - raw_bounds[0]
|
||||
boundaries = [b + delta for b in raw_bounds]
|
||||
|
||||
info = {
|
||||
"anchors": anchors,
|
||||
"boundaries": boundaries,
|
||||
"observed_left": observed_left,
|
||||
"observed_right": observed_right,
|
||||
"y_min": y_min,
|
||||
"y_max": y_max,
|
||||
}
|
||||
if os.getenv("PDF_EDITOR_TABLE_DEBUG"):
|
||||
logger.debug(
|
||||
"[PDF-EDITOR] table detected anchors=%s boundaries=%s y=(%.2f, %.2f) span=(%.2f, %.2f)",
|
||||
anchors,
|
||||
boundaries,
|
||||
y_min,
|
||||
y_max,
|
||||
observed_left,
|
||||
observed_right,
|
||||
)
|
||||
span = boundaries[-1] - boundaries[0]
|
||||
if span <= 0 or page_width <= 0:
|
||||
return None
|
||||
target_left = max(0.0, (page_width - span) * 0.5)
|
||||
offset = target_left - boundaries[0]
|
||||
info.update({"offset": offset, "page_width": page_width})
|
||||
return info
|
||||
|
||||
|
||||
def _snap_table_elements(text_elements: List[Dict[str, Any]], info: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
anchors = info["anchors"]
|
||||
base_boundaries = info["boundaries"]
|
||||
offset = info.get("offset", 0.0)
|
||||
boundaries = [b + offset for b in base_boundaries]
|
||||
y_min = info["y_min"]
|
||||
y_max = info["y_max"]
|
||||
left = info["observed_left"] + offset
|
||||
right = info["observed_right"] + offset
|
||||
|
||||
def _assign_col(cx: float) -> int:
|
||||
return min(range(len(anchors)), key=lambda i: abs(anchors[i] - cx))
|
||||
|
||||
EPS = 1.0
|
||||
PAD = 6.0
|
||||
|
||||
for el in text_elements:
|
||||
x = el.get("x")
|
||||
w = el.get("width") or 0
|
||||
y = el.get("y")
|
||||
if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
|
||||
continue
|
||||
cx = x + w * 0.5 + offset
|
||||
if not (left - PAD <= cx <= right + PAD and y_min - PAD <= y <= y_max + PAD):
|
||||
continue
|
||||
|
||||
col = _assign_col(cx - offset)
|
||||
col_left = boundaries[col]
|
||||
col_right = boundaries[col + 1]
|
||||
|
||||
new_left = x + offset
|
||||
new_right = x + w + offset
|
||||
if new_left < col_left - EPS:
|
||||
new_left = col_left
|
||||
if new_right > col_right + EPS:
|
||||
new_right = col_right
|
||||
if new_right <= new_left:
|
||||
mid = (col_left + col_right) * 0.5
|
||||
new_left = mid - 0.5
|
||||
new_right = mid + 0.5
|
||||
|
||||
el["x"] = new_left
|
||||
el["width"] = max(1.0, new_right - new_left)
|
||||
el["textMatrix"] = [1, 0, 0, 1, el["x"], el["y"]]
|
||||
|
||||
return text_elements
|
||||
|
||||
|
||||
def _parse_page(page_elem: ET.Element, base_dir: str, font_colors: Dict[str, Optional[str]]) -> Dict[str, Any]:
|
||||
page_width = _safe_float(page_elem.attrib.get("width"), 612.0)
|
||||
page_height = _safe_float(page_elem.attrib.get("height"), 792.0)
|
||||
text_elements: List[Dict[str, Any]] = []
|
||||
image_elements: List[Dict[str, Any]] = []
|
||||
|
||||
# Cluster near-identical text draws on the same baseline and keep the best candidate.
|
||||
EPS = 1.0
|
||||
best_by_pos: Dict[tuple[int, int], Dict[str, Any]] = {}
|
||||
|
||||
def _q(value: float) -> int:
|
||||
try:
|
||||
return int(round(value / EPS))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _score_text(t: str) -> tuple[int, int, int]:
|
||||
stripped = t.strip()
|
||||
has_currency = 1 if any(sym in stripped for sym in ("$", "€", "£", "¥")) else 0
|
||||
non_space = sum(1 for ch in stripped if not ch.isspace())
|
||||
digits = sum(1 for ch in stripped if ch.isdigit())
|
||||
return (has_currency, non_space, digits)
|
||||
|
||||
for index, text_elem in enumerate(page_elem.findall("text")):
|
||||
raw_text = "".join(text_elem.itertext()).replace("\u00A0", " ")
|
||||
text = raw_text.strip("\n")
|
||||
left = _safe_float(text_elem.attrib.get("left"))
|
||||
top = _safe_float(text_elem.attrib.get("top"))
|
||||
width = _safe_float(text_elem.attrib.get("width"))
|
||||
height = _safe_float(text_elem.attrib.get("height"))
|
||||
font_id = text_elem.attrib.get("font")
|
||||
font_color = font_colors.get(font_id) if font_id else None
|
||||
fill_components = _parse_color_components(font_color)
|
||||
|
||||
candidate = {
|
||||
"id": f"t-{index}",
|
||||
"text": text,
|
||||
"fontId": font_id,
|
||||
"fontSize": height if height > 0 else None,
|
||||
"x": left,
|
||||
"y": page_height - top,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"textMatrix": [1, 0, 0, 1, left, page_height - top],
|
||||
"fillColor": {"colorSpace": "RGB", "components": fill_components} if fill_components else None,
|
||||
}
|
||||
|
||||
pos_key = (_q(left), _q(page_height - top))
|
||||
|
||||
existing = best_by_pos.get(pos_key)
|
||||
if existing is None:
|
||||
best_by_pos[pos_key] = candidate
|
||||
else:
|
||||
if _score_text(candidate["text"]) > _score_text(existing["text"]):
|
||||
best_by_pos[pos_key] = candidate
|
||||
|
||||
# Optional merge of adjacent runs on the same baseline (e.g., "$" + "38.00")
|
||||
merged: List[Dict[str, Any]] = []
|
||||
base_elements = _dedupe_by_xy_text(sorted(best_by_pos.values(), key=lambda i: (i["y"], i["x"])), eps=1.0)
|
||||
# Sort by baseline (y) then x to make merges stable and ordering deterministic
|
||||
for item in sorted(base_elements, key=lambda i: (i["y"], i["x"])):
|
||||
if not merged:
|
||||
merged.append(item)
|
||||
continue
|
||||
prev = merged[-1]
|
||||
same_line = _q(prev["y"]) == _q(item["y"])
|
||||
if not same_line:
|
||||
merged.append(item)
|
||||
continue
|
||||
prev_right = prev["x"] + (prev.get("width") or 0)
|
||||
gap = item["x"] - prev_right
|
||||
max_h = max(prev.get("height") or 0, item.get("height") or 0)
|
||||
allowed_gap = max(2.0, 0.25 * max_h)
|
||||
if gap <= allowed_gap and gap >= -allowed_gap:
|
||||
# Merge
|
||||
needs_space = (
|
||||
prev["text"].strip() != ""
|
||||
and item["text"].strip() != ""
|
||||
and not prev["text"].endswith(" ")
|
||||
and not item["text"].startswith(" ")
|
||||
and not prev["text"].rstrip().endswith(("$", "€", "£", "¥"))
|
||||
)
|
||||
merged_text = prev["text"] + (" " if needs_space else "") + item["text"]
|
||||
new_left = min(prev["x"], item["x"])
|
||||
new_right = max(prev_right, item["x"] + (item.get("width") or 0))
|
||||
prev.update(
|
||||
{
|
||||
"text": merged_text,
|
||||
"x": new_left,
|
||||
"width": new_right - new_left,
|
||||
"height": max_h,
|
||||
# keep y and fontId from the left-most run
|
||||
}
|
||||
)
|
||||
else:
|
||||
merged.append(item)
|
||||
|
||||
# Phase A: dedupe by xy+text to remove duplicate draws
|
||||
deduped = _dedupe_by_xy_text(merged, eps=1.0)
|
||||
|
||||
# Phase B: table detection + snapping (header-agnostic)
|
||||
table_info = _detect_table_region(deduped, page_width)
|
||||
if table_info:
|
||||
snapped = _snap_table_elements(deduped, table_info)
|
||||
text_elements = _dedupe_by_xy_text(snapped, eps=1.0)
|
||||
else:
|
||||
text_elements = deduped
|
||||
|
||||
for img_index, image_elem in enumerate(page_elem.findall("image")):
|
||||
left = _safe_float(image_elem.attrib.get("left"))
|
||||
top = _safe_float(image_elem.attrib.get("top"))
|
||||
width = _safe_float(image_elem.attrib.get("width"))
|
||||
height = _safe_float(image_elem.attrib.get("height"))
|
||||
src = image_elem.attrib.get("src")
|
||||
image_path = os.path.join(base_dir, src) if src else None
|
||||
data_url = _read_image_as_data_url(image_path) if image_path else None
|
||||
image_elements.append(
|
||||
{
|
||||
"id": src or f"image-{img_index}",
|
||||
"objectName": src,
|
||||
"x": left,
|
||||
"y": max(page_height - top - height, 0),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"left": left,
|
||||
"top": top,
|
||||
"bottom": max(page_height - top, 0),
|
||||
"right": left + width,
|
||||
"imageData": data_url,
|
||||
"imageFormat": os.path.splitext(src)[1][1:] if src else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"width": page_width,
|
||||
"height": page_height,
|
||||
"pageNumber": _safe_float(page_elem.attrib.get("number"), 0),
|
||||
"textElements": text_elements,
|
||||
"imageElements": image_elements,
|
||||
}
|
||||
|
||||
|
||||
def convert_pdf_to_text_editor_document(pdf_path: str) -> Dict[str, Any]:
|
||||
"""Convert a PDF to a JSON payload usable by the PDF text editor."""
|
||||
if not os.path.exists(pdf_path):
|
||||
raise FileNotFoundError(pdf_path)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_base = os.path.join(tmpdir, "doc")
|
||||
command = [
|
||||
"pdftohtml",
|
||||
"-xml",
|
||||
"-enc",
|
||||
"UTF-8",
|
||||
"-nodrm",
|
||||
"-q",
|
||||
pdf_path,
|
||||
output_base,
|
||||
]
|
||||
try:
|
||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
logger.error("[PDF-EDITOR] pdftohtml failed for %s: %s", pdf_path, exc)
|
||||
raise
|
||||
|
||||
xml_path = f"{output_base}.xml"
|
||||
if not os.path.exists(xml_path):
|
||||
raise FileNotFoundError(xml_path)
|
||||
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
fonts = _parse_fonts(root)
|
||||
font_colors = {font["id"]: font.get("color") for font in fonts if font.get("id")}
|
||||
pages = [_parse_page(page_elem, tmpdir, font_colors) for page_elem in root.findall("page")]
|
||||
|
||||
document: Dict[str, Any] = {
|
||||
"metadata": {"numberOfPages": len(pages)},
|
||||
"fonts": fonts,
|
||||
"pages": pages,
|
||||
"lazyImages": False,
|
||||
}
|
||||
return {"document": document}
|
||||
|
||||
|
||||
__all__ = ["convert_pdf_to_text_editor_document"]
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from config import OUTPUT_DIR, logger
|
||||
|
||||
|
||||
_FONT_SPEC_MARKERS = (
|
||||
"\\usepackage{fontspec}",
|
||||
"\\setmainfont",
|
||||
"\\setsansfont",
|
||||
"\\setmonofont",
|
||||
"\\newfontfamily",
|
||||
)
|
||||
|
||||
|
||||
def _needs_unicode_engine(latex_code: str) -> bool:
|
||||
return any(marker in latex_code for marker in _FONT_SPEC_MARKERS)
|
||||
|
||||
|
||||
def _run_latex(engine: str, tex_filename: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[engine, "-interaction=nonstopmode", "-output-directory", OUTPUT_DIR, tex_filename],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=30,
|
||||
cwd=OUTPUT_DIR,
|
||||
)
|
||||
|
||||
|
||||
def render_pdf_to_images(pdf_bytes: bytes, max_pages: int = 2, dpi: int = 160) -> List[str]:
|
||||
"""Render the first N pages of a PDF to base64-encoded PNG data URLs."""
|
||||
images: List[str] = []
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
pdf_path = os.path.join(tmpdir, "upload.pdf")
|
||||
with open(pdf_path, "wb") as handle:
|
||||
handle.write(pdf_bytes)
|
||||
|
||||
output_prefix = os.path.join(tmpdir, "page")
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"pdftoppm",
|
||||
"-png",
|
||||
"-r",
|
||||
str(dpi),
|
||||
"-f",
|
||||
"1",
|
||||
"-l",
|
||||
str(max_pages),
|
||||
pdf_path,
|
||||
output_prefix,
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("[IMPORT] pdftoppm failed: %s", exc)
|
||||
return images
|
||||
|
||||
for idx in range(1, max_pages + 1):
|
||||
img_path = f"{output_prefix}-{idx}.png"
|
||||
if os.path.exists(img_path):
|
||||
with open(img_path, "rb") as img_handle:
|
||||
encoded = base64.b64encode(img_handle.read()).decode("utf-8")
|
||||
images.append(f"data:image/png;base64,{encoded}")
|
||||
return images
|
||||
|
||||
|
||||
def compile_latex_to_pdf(
|
||||
latex_code: str,
|
||||
job_id: str,
|
||||
*,
|
||||
log_errors: bool = True,
|
||||
raise_on_error: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Compile a LaTeX document and return the PDF path."""
|
||||
tex_filename = os.path.join(OUTPUT_DIR, f"{job_id}.tex")
|
||||
pdf_filename = f"{job_id}.pdf"
|
||||
pdf_path = os.path.join(OUTPUT_DIR, pdf_filename)
|
||||
|
||||
with open(tex_filename, "w", encoding="utf-8") as handle:
|
||||
handle.write(latex_code)
|
||||
|
||||
try:
|
||||
t_start = time.perf_counter()
|
||||
engine = "xelatex" if _needs_unicode_engine(latex_code) else "pdflatex"
|
||||
first = _run_latex(engine, tex_filename)
|
||||
if first.returncode != 0 and engine == "pdflatex":
|
||||
error_output = first.stderr.decode() or first.stdout.decode()
|
||||
if "fontspec" in error_output and ("XeTeX" in error_output or "LuaTeX" in error_output):
|
||||
logger.info("[PDF] pdflatex failed due to fontspec; retrying with xelatex")
|
||||
engine = "xelatex"
|
||||
first = _run_latex(engine, tex_filename)
|
||||
t_first = time.perf_counter()
|
||||
if first.returncode == 0:
|
||||
_run_latex(engine, tex_filename)
|
||||
t_end = time.perf_counter()
|
||||
|
||||
if os.path.exists(pdf_path):
|
||||
logger.info(
|
||||
"[PDF] compiled job_id=%s -> %s (engine=%s first_pass=%.2fs total=%.2fs)",
|
||||
job_id,
|
||||
pdf_filename,
|
||||
engine,
|
||||
t_first - t_start,
|
||||
t_end - t_start,
|
||||
)
|
||||
return pdf_path
|
||||
|
||||
error_output = first.stderr.decode() or first.stdout.decode()
|
||||
message = error_output.strip() or f"{engine} failed without stderr output"
|
||||
if log_errors:
|
||||
logger.error(
|
||||
"[PDF] not generated job_id=%s code=%s engine=%s after %.2fs: %s",
|
||||
job_id,
|
||||
first.returncode,
|
||||
engine,
|
||||
t_first - t_start,
|
||||
message,
|
||||
)
|
||||
if raise_on_error:
|
||||
raise RuntimeError(message)
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
message = f"LaTeX compilation timed out for job_id={job_id}"
|
||||
if log_errors:
|
||||
logger.error(message)
|
||||
if raise_on_error:
|
||||
raise
|
||||
return None
|
||||
except Exception as exc:
|
||||
if log_errors:
|
||||
logger.error("LaTeX compilation failed: %s", exc)
|
||||
if raise_on_error:
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["render_pdf_to_images", "compile_latex_to_pdf"]
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
|
||||
# Shared rules
|
||||
ALLOWED_LATEX_PACKAGES = [
|
||||
"courier",
|
||||
"graphicx",
|
||||
"geometry",
|
||||
"helvet",
|
||||
"lmodern",
|
||||
"mathpazo",
|
||||
"xcolor",
|
||||
"tabularx",
|
||||
"paracol",
|
||||
"multicol",
|
||||
"longtable",
|
||||
"setspace",
|
||||
"enumitem",
|
||||
"titlesec",
|
||||
"array",
|
||||
"inputenc",
|
||||
"fontenc",
|
||||
"tikz",
|
||||
]
|
||||
|
||||
LATEX_RULES = [
|
||||
r"Output must start with \documentclass.",
|
||||
r"Output must contain exactly one \begin{document} and one \end{document}.",
|
||||
r"Do not output anything before \documentclass.",
|
||||
r"Do not output anything after \end{document}.",
|
||||
"Do not include commentary, apologies, or markdown fences.",
|
||||
"If uncertain, prefer simpler LaTeX over complex packages.",
|
||||
"Only use LaTeX packages from the allowlist in this prompt.",
|
||||
"Do not use fontspec or custom font commands.",
|
||||
]
|
||||
|
||||
|
||||
def latex_system_prompt(style_profile: Dict[str, Any], document_type: str, template_hint: Optional[str]) -> str:
|
||||
safe_style = {
|
||||
"font_preference": style_profile.get("font_preference", "default"),
|
||||
"tone": style_profile.get("tone", "professional"),
|
||||
"color_accent": style_profile.get("color_accent", "blue"),
|
||||
"layout_preference": style_profile.get("layout_preference", "clean"),
|
||||
}
|
||||
return (
|
||||
"You are a LaTeX document generator for PDFs.\n"
|
||||
f"User Style Profile (trimmed): {json.dumps(safe_style, sort_keys=True)}\n"
|
||||
f"Document Type: {document_type}\n"
|
||||
f"Template Hint present: {'yes' if template_hint else 'no'}\n"
|
||||
"Rules:\n"
|
||||
"1) Output ONLY valid LaTeX code.\n"
|
||||
f"- " + "\n- ".join(LATEX_RULES) + "\n"
|
||||
"2) Use only the allowlisted packages:\n"
|
||||
f"- " + "\n- ".join(ALLOWED_LATEX_PACKAGES) + "\n"
|
||||
f"3) Respect preferred font ({safe_style['font_preference']}), tone ({safe_style['tone']}), and color accent ({safe_style['color_accent']}). Use the accent token name 'accent' (e.g., \\color{{accent}} or \\textcolor{{accent}}{{...}}).\n"
|
||||
"4) If a template hint is provided, stay close to its layout and styling.\n"
|
||||
"5) Do NOT add placeholder images or black boxes; omit images entirely unless an explicit path or real image content is provided. Do not use \\rule, tikz, or colored rectangles as image stand-ins.\n"
|
||||
"6) Return a full compilable document."
|
||||
)
|
||||
|
||||
|
||||
def latex_context_messages(
|
||||
template_hint: Optional[str],
|
||||
current_latex: Optional[str],
|
||||
structured_brief: Optional[str],
|
||||
) -> List[Dict[str, str]]:
|
||||
messages: List[Dict[str, str]] = []
|
||||
if template_hint:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"REFERENCE TEMPLATE (keep style/layout, do not copy data):\n---\n{template_hint[:2000]}\n---",
|
||||
}
|
||||
)
|
||||
if current_latex:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"CURRENT LATEX DRAFT (keep structure, apply edits):\n---\n{current_latex[:2000]}\n---",
|
||||
}
|
||||
)
|
||||
if structured_brief:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Structured details gathered from the user (authoritative; do not invent beyond this):\n"
|
||||
f"---\n{structured_brief}\n---"
|
||||
),
|
||||
}
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
def pdf_qa_system_prompt() -> str:
|
||||
return (
|
||||
"You are a helpful assistant. Read the provided PDF text and answer the user's question.\n"
|
||||
"Respond with:\n"
|
||||
"Answer: 2–4 sentences summarizing the answer from the text.\n"
|
||||
"Evidence: 1–3 short quotes/snippets from the provided text (must be exact substrings).\n"
|
||||
"If the answer is not in the text, say: 'Not found in the provided text.' and give a best-effort summary."
|
||||
)
|
||||
|
||||
|
||||
def brief_missing_info_system_prompt(doc_type: str) -> str:
|
||||
return (
|
||||
f"You are a brief-gathering assistant for generating a {doc_type}.\n"
|
||||
"Be conversational and concise. Ask at most 3 short questions; no multi-part questions.\n"
|
||||
"If the user hasn't given much, invite them to paste prior material or dump everything they remember.\n"
|
||||
"Do not invent data; only ask."
|
||||
)
|
||||
|
||||
|
||||
def vision_layout_system_prompt() -> str:
|
||||
return (
|
||||
"You are a LaTeX layout extractor. Given page images of a PDF, return a LaTeX skeleton matching the layout and styling while blanking user content.\n"
|
||||
"Do NOT copy any readable text from images. Replace all text with placeholders like TITLE HERE, LOREM, XXXX.\n"
|
||||
"Infer margins, columns, header/footer, tables. Use \\rule{width}{height} placeholders sized to match blocks.\n"
|
||||
"Use common packages (geometry, xcolor, tabularx, multicol, paracol, tikz). Replace text with placeholders and output a full compilable document.\n"
|
||||
"Output ONLY LaTeX."
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"latex_system_prompt",
|
||||
"latex_context_messages",
|
||||
"pdf_qa_system_prompt",
|
||||
"brief_missing_info_system_prompt",
|
||||
"vision_layout_system_prompt",
|
||||
"LATEX_RULES",
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
Flask==3.0.0
|
||||
Flask-CORS==4.0.0
|
||||
# Use modern OpenAI SDK (v1 interface)
|
||||
openai>=1.12.0
|
||||
langchain-core==1.2.5
|
||||
langchain-openai==1.1.6
|
||||
@@ -0,0 +1,18 @@
|
||||
from prompts import (
|
||||
pdf_qa_system_prompt,
|
||||
vision_layout_system_prompt,
|
||||
latex_system_prompt,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
assert pdf_qa_system_prompt(), "pdf_qa_system_prompt is empty"
|
||||
assert "Do NOT copy" in vision_layout_system_prompt(), "vision prompt missing no-copy rule"
|
||||
latex_prompt = latex_system_prompt({}, "document", None)
|
||||
assert "\\end{document}" in latex_prompt, "latex prompt missing end document mention"
|
||||
print("prompts OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
TEMPLATE_ROOT = ROOT_DIR / "backend" / "templates"
|
||||
FRONTEND_PUBLIC = ROOT_DIR / "frontend" / "public" / "templates"
|
||||
FRONTEND_CATALOG = ROOT_DIR / "frontend" / "src" / "templateCatalog.ts"
|
||||
TIMEOUT_SEC = 60
|
||||
|
||||
LOREM_SENTENCE = (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt "
|
||||
"ut labore et dolore magna aliqua."
|
||||
)
|
||||
LOREM_PARAGRAPH = (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt "
|
||||
"ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco "
|
||||
"laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in "
|
||||
"voluptate velit esse cillum dolore eu fugiat nulla pariatur."
|
||||
)
|
||||
LOREM_SHORT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
||||
|
||||
PLACEHOLDER_REPLACEMENTS = {
|
||||
"TITLE": "Sample Title",
|
||||
"SUBTITLE": "Sample Subtitle",
|
||||
"AUTHOR": "Sample Author",
|
||||
"AUTHOR_LIST": "Sample Author One, Sample Author Two",
|
||||
"AFFILIATIONS": "Sample Organization",
|
||||
"ABSTRACT": LOREM_PARAGRAPH,
|
||||
"KEYWORDS": "keyword1, keyword2, keyword3, keyword4",
|
||||
"INTRODUCTION": LOREM_PARAGRAPH,
|
||||
"RELATED_WORK": LOREM_PARAGRAPH,
|
||||
"METHODOLOGY": LOREM_PARAGRAPH,
|
||||
"RESULTS": LOREM_PARAGRAPH,
|
||||
"DISCUSSION": LOREM_PARAGRAPH,
|
||||
"CONCLUSION": LOREM_SHORT,
|
||||
"REFERENCES": "Doe, J. (2024). Example Reference. Journal of Examples.",
|
||||
"MAIN_TEXT": f"{LOREM_PARAGRAPH} {LOREM_PARAGRAPH}",
|
||||
"FIGURES_TABLES": "Figure 1: Example chart. Table 1: Summary of results.",
|
||||
"REPORT_TITLE": "Business Report",
|
||||
"DATE": "2025-01-01",
|
||||
"EXEC_SUMMARY": LOREM_PARAGRAPH,
|
||||
"BACKGROUND": LOREM_PARAGRAPH,
|
||||
"FINDINGS": f"{LOREM_SENTENCE} {LOREM_SENTENCE}",
|
||||
"RECOMMENDATIONS": "Recommendation 1: Improve efficiency. Recommendation 2: Reduce costs.",
|
||||
"APPENDIX": LOREM_SHORT,
|
||||
"NEWSLETTER_TITLE": "Monthly Newsletter",
|
||||
"TOP_STORY": LOREM_PARAGRAPH,
|
||||
"UPDATES": f"{LOREM_SENTENCE} {LOREM_SENTENCE}",
|
||||
"SPOTLIGHT": LOREM_PARAGRAPH,
|
||||
"FOOTER": "Contact: info@example.com | 123 Main Street",
|
||||
"RECIPE_TITLE": "Sample Recipe",
|
||||
"SERVINGS": "Serves 4",
|
||||
"TIME": "30 minutes",
|
||||
"INGREDIENTS": r"\begin{itemize}\item Ingredient A\item Ingredient B\item Ingredient C\end{itemize}",
|
||||
"INSTRUCTIONS": LOREM_PARAGRAPH,
|
||||
"NOTES": "Notes and tips: adjust seasoning to taste.",
|
||||
"BUSINESS_NAME": "Your Company",
|
||||
"BUSINESS_ADDRESS": "123 Main Street, Springfield",
|
||||
"BUSINESS_CONTACT": "email@example.com | (555) 555-5555",
|
||||
"INVOICE_NUMBER": "INV-001",
|
||||
"ISSUE_DATE": "2025-01-01",
|
||||
"DUE_DATE": "2025-01-15",
|
||||
"CLIENT_NAME": "Client Name",
|
||||
"CLIENT_ADDRESS": "456 Client Ave, Metropolis",
|
||||
"CLIENT_CONTACT": "client@example.com",
|
||||
"LINE_ITEMS": r"Design Services & 8 & 120 & 960 \\ Consulting & 4 & 150 & 600 \\",
|
||||
"SUBTOTAL": "1560",
|
||||
"TAXES": "124.80",
|
||||
"TOTAL": "1684.80",
|
||||
"PAYMENT_TERMS": "Net 15",
|
||||
"PAYMENT_METHODS": "Bank transfer, credit card",
|
||||
"STUDENT_NAME": "Student Name",
|
||||
"COURSE_NAME": "Course Name",
|
||||
"INSTRUCTOR_NAME": "Instructor Name",
|
||||
"ASSIGNMENT_TITLE": "Assignment Title",
|
||||
"PROMPT": LOREM_SHORT,
|
||||
"RESPONSE": LOREM_PARAGRAPH,
|
||||
"CHAPTER_ONE_TITLE": "Chapter One",
|
||||
"CHAPTER_ONE": LOREM_PARAGRAPH,
|
||||
"CHAPTER_TWO_TITLE": "Chapter Two",
|
||||
"CHAPTER_TWO": LOREM_PARAGRAPH,
|
||||
"PREFACE": LOREM_SHORT,
|
||||
"PUBLISHER": "Publisher",
|
||||
"NAME": "Name",
|
||||
"EMAIL": "email@example.com",
|
||||
"PHONE": "(555) 555-5555",
|
||||
"LOCATION": "City, Country",
|
||||
"SUMMARY": LOREM_SENTENCE,
|
||||
"EXPERIENCE": f"{LOREM_SENTENCE} {LOREM_SENTENCE}",
|
||||
"EDUCATION": "University Name, B.S. in Example Studies",
|
||||
"SKILLS": "Skills: Analysis, Design, Communication",
|
||||
"PROJECTS": LOREM_SHORT,
|
||||
"SUBJECT": "Subject",
|
||||
"BODY": LOREM_PARAGRAPH,
|
||||
"RECIPIENT_NAME": "Recipient Name",
|
||||
"RECIPIENT_TITLE": "Recipient Title",
|
||||
"RECIPIENT_COMPANY": "Recipient Company",
|
||||
"RECIPIENT_ADDRESS": "Recipient Address",
|
||||
"SENDER_NAME": "Sender Name",
|
||||
"SENDER_ADDRESS": "Sender Address",
|
||||
"SENDER_EMAIL": "sender@example.com",
|
||||
"MONTH_YEAR": "January 2025",
|
||||
"THEME": "Theme",
|
||||
"WEEK_ROWS": "1 & 2 & 3 & 4 & 5 & 6 & 7 \\\\\\\\ \\\\hline",
|
||||
"HEADLINE": "Headline",
|
||||
"SUBTEXT": "Supporting message with a clear benefit.",
|
||||
"CALL_TO_ACTION": "Call to action",
|
||||
"CONTACT": "contact@example.com",
|
||||
"EXPERIMENT_TITLE": "Experiment Title",
|
||||
"OBJECTIVE": LOREM_SHORT,
|
||||
"MATERIALS": "Materials list goes here.",
|
||||
"PROCEDURE": LOREM_PARAGRAPH,
|
||||
"OBSERVATIONS": LOREM_SHORT,
|
||||
"INSTITUTION": "Institution",
|
||||
"PRESENTER": "Presenter",
|
||||
"AGENDA": "Agenda goes here.",
|
||||
"KEY_POINTS": "Key points go here.",
|
||||
"DATA_VISUALS": "Data visuals go here.",
|
||||
"SUBTITLE": "Subtitle",
|
||||
"ORGANIZATION": "Organization",
|
||||
}
|
||||
|
||||
|
||||
def render_template_latex(raw_latex: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1).strip()
|
||||
return PLACEHOLDER_REPLACEMENTS.get(key, key.replace("_", " ").title())
|
||||
|
||||
return re.sub(r"<<([A-Z0-9_]+)>>", replace, raw_latex)
|
||||
|
||||
|
||||
def find_converter() -> str | None:
|
||||
if shutil.which("pdftoppm"):
|
||||
return "pdftoppm"
|
||||
if shutil.which("magick"):
|
||||
return "magick"
|
||||
if shutil.which("convert"):
|
||||
return "convert"
|
||||
return None
|
||||
|
||||
|
||||
def pdf_to_jpg(pdf_path: Path, jpg_path: Path, converter: str) -> None:
|
||||
if converter == "pdftoppm":
|
||||
subprocess.run(
|
||||
["pdftoppm", "-jpeg", "-f", "1", "-singlefile", str(pdf_path), str(jpg_path.with_suffix(''))],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=TIMEOUT_SEC,
|
||||
)
|
||||
return
|
||||
if converter == "magick":
|
||||
subprocess.run(
|
||||
["magick", "convert", "-density", "150", str(pdf_path), "-quality", "90", str(jpg_path)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=TIMEOUT_SEC,
|
||||
)
|
||||
return
|
||||
subprocess.run(
|
||||
["convert", "-density", "150", str(pdf_path), "-quality", "90", str(jpg_path)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=TIMEOUT_SEC,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not shutil.which("pdflatex"):
|
||||
raise SystemExit("pdflatex not found. Install TeX Live or MikTeX to generate thumbnails.")
|
||||
|
||||
converter = find_converter()
|
||||
if not converter:
|
||||
raise SystemExit("No PDF-to-image converter found. Install poppler-utils or ImageMagick.")
|
||||
|
||||
tex_files = list(TEMPLATE_ROOT.rglob("*.tex"))
|
||||
if not tex_files:
|
||||
raise SystemExit(f"No templates found in {TEMPLATE_ROOT}")
|
||||
|
||||
FRONTEND_PUBLIC.mkdir(parents=True, exist_ok=True)
|
||||
catalog: dict[str, list[str]] = {}
|
||||
|
||||
failures: list[str] = []
|
||||
for tex_file in tex_files:
|
||||
doc_type = tex_file.parent.name
|
||||
template_id = tex_file.stem
|
||||
target_dir = FRONTEND_PUBLIC / doc_type
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_jpg = target_dir / f"{template_id}.jpg"
|
||||
|
||||
catalog.setdefault(doc_type, [])
|
||||
if template_id not in catalog[doc_type]:
|
||||
catalog[doc_type].append(template_id)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
rendered = render_template_latex(tex_file.read_text(encoding="ascii"))
|
||||
tmp_tex = tmpdir_path / "template.tex"
|
||||
tmp_tex.write_text(rendered, encoding="ascii")
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["pdflatex", "-interaction=nonstopmode", "-halt-on-error", tmp_tex.name],
|
||||
check=True,
|
||||
cwd=tmpdir_path,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=TIMEOUT_SEC,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
failures.append(f"{tex_file}: pdflatex failed ({exc})")
|
||||
continue
|
||||
|
||||
pdf_path = tmpdir_path / "template.pdf"
|
||||
if not pdf_path.exists():
|
||||
failures.append(f"{tex_file}: PDF not generated")
|
||||
continue
|
||||
|
||||
try:
|
||||
pdf_to_jpg(pdf_path, target_jpg, converter)
|
||||
print(f"Wrote {target_jpg}")
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
failures.append(f"{tex_file}: image conversion failed ({exc})")
|
||||
continue
|
||||
|
||||
if failures:
|
||||
print("\nFailures:")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
raise SystemExit("Template thumbnail generation completed with errors.")
|
||||
|
||||
entries = []
|
||||
for doc_type in sorted(catalog.keys()):
|
||||
templates = sorted(catalog[doc_type])
|
||||
if "default" in templates:
|
||||
templates = ["default"] + [t for t in templates if t != "default"]
|
||||
entries.append(
|
||||
f" {{ docType: '{doc_type}', templateCount: {len(templates)}, templates: {templates} }}"
|
||||
)
|
||||
|
||||
FRONTEND_CATALOG.write_text(
|
||||
"// Auto-generated by generate_template_thumbnails.py\n"
|
||||
"export type TemplateCatalogEntry = {\n"
|
||||
" docType: string\n"
|
||||
" templateCount: number\n"
|
||||
" templates: string[]\n"
|
||||
"}\n\n"
|
||||
"export const templateCatalog: TemplateCatalogEntry[] = [\n"
|
||||
+ ",\n".join(entries)
|
||||
+ "\n]\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
print(f"Wrote catalog {FRONTEND_CATALOG}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from config import STYLE_DB_PATH, TEMPLATE_DB_PATH, VERSIONS_DB_PATH
|
||||
from latex_utils import clean_generated_latex, extract_layout_hint
|
||||
|
||||
|
||||
def _read_json(path: str) -> Dict[str, Any]:
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _write_json(path: str, data: Dict[str, Any]) -> None:
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
|
||||
|
||||
def load_user_style(user_id: str) -> Dict[str, Any]:
|
||||
"""Load (or bootstrap) an individual user's preferred style."""
|
||||
data = _read_json(STYLE_DB_PATH)
|
||||
return data.get(
|
||||
user_id,
|
||||
{
|
||||
"layout_preference": "clean",
|
||||
"font_preference": "helvet",
|
||||
"tone": "professional",
|
||||
"color_accent": "blue",
|
||||
"last_doc_type": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def save_user_style(user_id: str, style_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Persist style preferences for a user."""
|
||||
all_data = _read_json(STYLE_DB_PATH)
|
||||
current = all_data.get(user_id, load_user_style(user_id))
|
||||
current.update(style_data)
|
||||
all_data[user_id] = current
|
||||
_write_json(STYLE_DB_PATH, all_data)
|
||||
return current
|
||||
|
||||
|
||||
def load_user_templates(user_id: str) -> Dict[str, str]:
|
||||
templates = _read_json(TEMPLATE_DB_PATH)
|
||||
return templates.get(user_id, {})
|
||||
|
||||
|
||||
def save_user_template(user_id: str, doc_type: str, latex_code: str) -> None:
|
||||
"""Persist sanitized layout hints per doc type."""
|
||||
templates = _read_json(TEMPLATE_DB_PATH)
|
||||
user_templates = templates.get(user_id, {})
|
||||
sanitized = clean_generated_latex(latex_code)
|
||||
user_templates[doc_type] = extract_layout_hint(sanitized)
|
||||
templates[user_id] = user_templates
|
||||
_write_json(TEMPLATE_DB_PATH, templates)
|
||||
|
||||
|
||||
def load_versions(user_id: str) -> List[Dict[str, Any]]:
|
||||
data = _read_json(VERSIONS_DB_PATH)
|
||||
return data.get(user_id, [])
|
||||
|
||||
|
||||
def save_version(user_id: str, entry: Dict[str, Any]) -> None:
|
||||
data = _read_json(VERSIONS_DB_PATH)
|
||||
versions = data.get(user_id, [])
|
||||
versions.insert(0, entry)
|
||||
data[user_id] = versions[:20]
|
||||
_write_json(VERSIONS_DB_PATH, data)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"load_user_style",
|
||||
"save_user_style",
|
||||
"load_user_templates",
|
||||
"save_user_template",
|
||||
"load_versions",
|
||||
"save_version",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from storage import load_user_style, save_user_style
|
||||
|
||||
|
||||
def update_style_profile_from_prompt(user_id: str, prompt: str) -> Dict[str, Any]:
|
||||
"""Simple heuristics to remember color/font/tone preferences from prompt text."""
|
||||
style = load_user_style(user_id)
|
||||
lower = (prompt or "").lower()
|
||||
|
||||
if "modern" in lower or "minimal" in lower:
|
||||
style["layout_preference"] = "modern"
|
||||
style["tone"] = "minimalist"
|
||||
if "classic" in lower or "formal" in lower:
|
||||
style["layout_preference"] = "classic"
|
||||
style["tone"] = "formal"
|
||||
if "serif" in lower:
|
||||
style["font_preference"] = "serif"
|
||||
if "sans" in lower:
|
||||
style["font_preference"] = "helvet"
|
||||
if "blue" in lower:
|
||||
style["color_accent"] = "blue"
|
||||
if "red" in lower:
|
||||
style["color_accent"] = "red"
|
||||
if "green" in lower:
|
||||
style["color_accent"] = "green"
|
||||
|
||||
return save_user_style(user_id, style)
|
||||
|
||||
|
||||
__all__ = ["update_style_profile_from_prompt"]
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional
|
||||
import time
|
||||
|
||||
from config import CLIENT_MODE, SMART_MODEL, get_chat_model, logger
|
||||
from langchain_utils import to_lc_messages
|
||||
from prompts import vision_layout_system_prompt
|
||||
|
||||
|
||||
def vision_layout_from_images(image_urls: List[str], doc_type: str) -> Optional[str]:
|
||||
"""Call the multimodal model to recover a LaTeX skeleton from page images."""
|
||||
if CLIENT_MODE != "langchain" or not image_urls:
|
||||
return None
|
||||
|
||||
system_prompt = vision_layout_system_prompt()
|
||||
|
||||
user_content: List[Any] = [
|
||||
{"type": "text", "text": f"Extract layout for document type: {doc_type}. Return LaTeX skeleton only."}
|
||||
]
|
||||
for url in image_urls:
|
||||
user_content.append({"type": "image_url", "image_url": {"url": url}})
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
try:
|
||||
logger.info("[IMPORT] vision call pages=%s doc_type=%s", len(image_urls), doc_type)
|
||||
llm = get_chat_model(SMART_MODEL, max_tokens=2800)
|
||||
if not llm:
|
||||
return None
|
||||
start = time.perf_counter()
|
||||
response = llm.invoke(to_lc_messages(messages))
|
||||
elapsed = time.perf_counter() - start
|
||||
content = response.content or ""
|
||||
usage = getattr(response, "usage_metadata", None)
|
||||
logger.info(
|
||||
"[IMPORT] vision model=%s elapsed=%.2fs chars=%s usage=%s",
|
||||
SMART_MODEL,
|
||||
elapsed,
|
||||
len(str(content)),
|
||||
usage,
|
||||
)
|
||||
return response.content
|
||||
except Exception as exc:
|
||||
logger.error("[IMPORT] vision generation failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["vision_layout_from_images"]
|
||||
@@ -0,0 +1,36 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
jsxPragma: null,
|
||||
},
|
||||
plugins: ['react', 'react-hooks', '@typescript-eslint'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
runtime: 'automatic',
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['dist', 'node_modules'],
|
||||
rules: {
|
||||
'react/prop-types': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>Stirling - Intelligent Document 1.0</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "latex-pdf-generator-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.0",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,134 @@
|
||||
import { LandingView } from './components/landing/LandingView'
|
||||
import { ImportLayoutModal } from './components/modals/ImportLayoutModal'
|
||||
import { WorkspaceView } from './components/workspace/WorkspaceView'
|
||||
import { useDocumentWorkflow } from './hooks/useDocumentWorkflow'
|
||||
import { useSpeechCapture } from './hooks/useSpeechCapture'
|
||||
|
||||
function App() {
|
||||
const workflow = useDocumentWorkflow()
|
||||
const speech = useSpeechCapture({ appendPrompt: workflow.appendPrompt })
|
||||
|
||||
const importModal = (
|
||||
<ImportLayoutModal
|
||||
isOpen={workflow.showImportModal}
|
||||
docType={workflow.importDocType}
|
||||
onDocTypeChange={workflow.setImportDocType}
|
||||
onClose={() => workflow.setShowImportModal(false)}
|
||||
onFileSelected={workflow.handleImportTemplate}
|
||||
isImporting={workflow.isImporting}
|
||||
status={workflow.importStatus}
|
||||
/>
|
||||
)
|
||||
|
||||
if (workflow.view === 'landing') {
|
||||
return (
|
||||
<>
|
||||
<LandingView
|
||||
prompt={workflow.prompt}
|
||||
onPromptChange={workflow.setPrompt}
|
||||
onSubmit={workflow.handleInitialSubmit}
|
||||
onKeyDown={workflow.handleKeyDown}
|
||||
uploadedPdfFile={workflow.uploadedPdfFile}
|
||||
onFileSelect={workflow.setUploadedPdfFile}
|
||||
isImporting={workflow.isImporting}
|
||||
onToggleRecording={speech.toggleRecording}
|
||||
onCancelRecording={speech.cancelRecording}
|
||||
onAcceptRecording={speech.acceptRecording}
|
||||
isRecording={speech.isRecording}
|
||||
whisperStatus={speech.whisperStatus}
|
||||
waveformHistory={speech.waveformHistory}
|
||||
docTypes={workflow.docTypes}
|
||||
templateCounts={workflow.templateCounts}
|
||||
templateCatalog={workflow.templateCatalog}
|
||||
selectedDocType={workflow.selectedDocType}
|
||||
selectedTemplateId={workflow.selectedTemplateId}
|
||||
templatesForSelected={workflow.templatesForSelected}
|
||||
isTemplateLoading={workflow.isTemplateLoading}
|
||||
isTemplatePanelOpen={workflow.isTemplatePanelOpen}
|
||||
onToggleTemplatePanel={() =>
|
||||
workflow.setIsTemplatePanelOpen(!workflow.isTemplatePanelOpen)
|
||||
}
|
||||
onSelectTemplate={workflow.applyTemplateSelection}
|
||||
templateThumbnailUrl={workflow.templateThumbnailUrl}
|
||||
formatDocLabel={(value: string) =>
|
||||
value
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
/>
|
||||
{importModal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceView
|
||||
isGenerating={workflow.isGenerating}
|
||||
isLivePreviewing={workflow.isLivePreviewing}
|
||||
isStageLoading={workflow.isStageLoading}
|
||||
prompt={workflow.prompt}
|
||||
onPromptChange={workflow.setPrompt}
|
||||
onChatSubmit={workflow.handleChatSubmit}
|
||||
onKeyDown={workflow.handleKeyDown}
|
||||
currentDoc={workflow.currentDoc}
|
||||
onBack={() => workflow.setView('landing')}
|
||||
stage={workflow.stage}
|
||||
outlineRows={workflow.outlineRows}
|
||||
outlineSections={workflow.outlineSections}
|
||||
excludedFields={workflow.excludedFields}
|
||||
outlineConstraints={workflow.outlineConstraints}
|
||||
draftRows={workflow.draftRows}
|
||||
setOutlineRows={workflow.setOutlineRows}
|
||||
setOutlineSections={workflow.setOutlineSections}
|
||||
setExcludedFields={workflow.setExcludedFields}
|
||||
setOutlineConstraints={workflow.setOutlineConstraints}
|
||||
setDraftRows={workflow.setDraftRows}
|
||||
docTypes={workflow.docTypes}
|
||||
templateCounts={workflow.templateCounts}
|
||||
selectedDocType={workflow.selectedDocType}
|
||||
selectedTemplateId={workflow.selectedTemplateId}
|
||||
templatesForSelected={workflow.templatesForSelected}
|
||||
isTemplateLoading={workflow.isTemplateLoading}
|
||||
onSelectTemplate={workflow.applyTemplateSelection}
|
||||
templateThumbnailUrl={workflow.templateThumbnailUrl}
|
||||
approveOutline={workflow.approveOutline}
|
||||
onAiOutline={() => {
|
||||
workflow.fillFieldsFromAI()
|
||||
}}
|
||||
approveDraft={workflow.approveDraft}
|
||||
saveAndReview={workflow.saveAndReview}
|
||||
styleDraft={workflow.styleDraft}
|
||||
setStyleDraft={workflow.setStyleDraft}
|
||||
applyStyleAndRegenerate={workflow.applyStyleAndRegenerate}
|
||||
onAddPromptInfo={workflow.addPromptForFields}
|
||||
onStageSelect={(nextStage) => {
|
||||
if (workflow.isGenerating || workflow.isStageLoading) return
|
||||
if (nextStage === 'text' && workflow.stage === 'outline') {
|
||||
workflow.approveOutline()
|
||||
return
|
||||
}
|
||||
if (nextStage === 'styling' && workflow.stage === 'text') {
|
||||
workflow.approveDraft()
|
||||
return
|
||||
}
|
||||
if (nextStage === 'review' && workflow.stage === 'styling') {
|
||||
workflow.saveAndReview()
|
||||
return
|
||||
}
|
||||
workflow.setStage(nextStage)
|
||||
}}
|
||||
imagePlaceholdersCount={workflow.imagePlaceholdersCount}
|
||||
isAssetUploading={workflow.isAssetUploading}
|
||||
assetError={workflow.assetError}
|
||||
onAddPlaceholderImage={workflow.addImageToPlaceholders}
|
||||
onRemovePlaceholders={workflow.stripImagePlaceholders}
|
||||
onOpenImportTemplate={() => workflow.openImportTemplate(workflow.selectedDocType)}
|
||||
/>
|
||||
{importModal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,360 @@
|
||||
import { FormEvent, KeyboardEvent, useMemo, useState } from 'react'
|
||||
import { AudioWaveform } from '../ui/AudioWaveform'
|
||||
|
||||
interface LandingViewProps {
|
||||
prompt: string
|
||||
onPromptChange: (value: string) => void
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
uploadedPdfFile: File | null
|
||||
onFileSelect: (file: File | null) => void
|
||||
isImporting: boolean
|
||||
onToggleRecording: () => void
|
||||
onCancelRecording: () => void
|
||||
onAcceptRecording: () => void
|
||||
isRecording: boolean
|
||||
whisperStatus: string | null
|
||||
waveformHistory: number[][]
|
||||
docTypes: string[]
|
||||
templateCounts: Record<string, number>
|
||||
templateCatalog: { docType: string; templateCount: number; templates: string[] }[]
|
||||
selectedDocType: string
|
||||
selectedTemplateId: string
|
||||
templatesForSelected: string[]
|
||||
isTemplateLoading: boolean
|
||||
isTemplatePanelOpen: boolean
|
||||
onToggleTemplatePanel: () => void
|
||||
onSelectTemplate: (docType: string, templateId: string) => void
|
||||
templateThumbnailUrl: (docType: string, templateId: string) => string
|
||||
formatDocLabel: (value: string) => string
|
||||
}
|
||||
|
||||
export function LandingView({
|
||||
prompt,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
uploadedPdfFile,
|
||||
onFileSelect,
|
||||
isImporting,
|
||||
onToggleRecording,
|
||||
onCancelRecording,
|
||||
onAcceptRecording,
|
||||
isRecording,
|
||||
whisperStatus,
|
||||
waveformHistory,
|
||||
docTypes,
|
||||
templateCounts,
|
||||
templateCatalog,
|
||||
selectedDocType,
|
||||
selectedTemplateId,
|
||||
templatesForSelected,
|
||||
isTemplateLoading,
|
||||
isTemplatePanelOpen,
|
||||
onToggleTemplatePanel,
|
||||
onSelectTemplate,
|
||||
templateThumbnailUrl,
|
||||
formatDocLabel,
|
||||
}: LandingViewProps) {
|
||||
const [templateSearch, setTemplateSearch] = useState('')
|
||||
const [activeTemplateTab, setActiveTemplateTab] = useState<'popular' | 'legal' | 'financial' | 'academic' | 'marketing' | 'operations'>('popular')
|
||||
const [expandedDocType, setExpandedDocType] = useState<string | null>(null)
|
||||
const [hoveredTemplate, setHoveredTemplate] = useState<{ docType: string; templateId: string; x: number; y: number } | null>(null)
|
||||
|
||||
const popularDocTypes = new Set(['cvs_and_resumes', 'invoices', 'cover_letters', 'business_reports', 'presentations'])
|
||||
const legalDocTypes = new Set(['formal_letters', 'theses'])
|
||||
const financialDocTypes = new Set(['invoices', 'business_reports', 'calendars'])
|
||||
const academicDocTypes = new Set(['academic_articles', 'academic_journals', 'assignments', 'theses'])
|
||||
const marketingDocTypes = new Set(['newsletters', 'signs', 'presentations'])
|
||||
const operationsDocTypes = new Set(['laboratory_reports', 'laboratory_books', 'business_reports'])
|
||||
|
||||
const visibleDocTypes = useMemo(() => {
|
||||
const filtered = docTypes.filter((docType) =>
|
||||
formatDocLabel(docType).toLowerCase().includes(templateSearch.toLowerCase()),
|
||||
)
|
||||
if (activeTemplateTab === 'legal') {
|
||||
return filtered.filter((docType) => legalDocTypes.has(docType))
|
||||
}
|
||||
if (activeTemplateTab === 'financial') {
|
||||
return filtered.filter((docType) => financialDocTypes.has(docType))
|
||||
}
|
||||
if (activeTemplateTab === 'academic') {
|
||||
return filtered.filter((docType) => academicDocTypes.has(docType))
|
||||
}
|
||||
if (activeTemplateTab === 'marketing') {
|
||||
return filtered.filter((docType) => marketingDocTypes.has(docType))
|
||||
}
|
||||
if (activeTemplateTab === 'operations') {
|
||||
return filtered.filter((docType) => operationsDocTypes.has(docType))
|
||||
}
|
||||
return filtered.filter((docType) => popularDocTypes.has(docType))
|
||||
}, [
|
||||
academicDocTypes,
|
||||
activeTemplateTab,
|
||||
docTypes,
|
||||
financialDocTypes,
|
||||
formatDocLabel,
|
||||
legalDocTypes,
|
||||
marketingDocTypes,
|
||||
operationsDocTypes,
|
||||
popularDocTypes,
|
||||
templateSearch,
|
||||
])
|
||||
|
||||
const docTypeIcon = (docType: string) => {
|
||||
switch (docType) {
|
||||
case 'cvs_and_resumes':
|
||||
return '📄'
|
||||
case 'invoices':
|
||||
return '🧾'
|
||||
case 'cover_letters':
|
||||
return '✉️'
|
||||
case 'business_reports':
|
||||
return '📊'
|
||||
case 'formal_letters':
|
||||
return '📝'
|
||||
case 'theses':
|
||||
return '🎓'
|
||||
case 'presentations':
|
||||
return '🖥️'
|
||||
case 'recipes':
|
||||
return '🍲'
|
||||
default:
|
||||
return '📁'
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 text-slate-900">
|
||||
<header className="flex items-center justify-between px-8 py-6">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<span className="text-base">Stirling</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="rounded-full border border-slate-200 px-4 py-1.5 text-xs text-slate-500">
|
||||
Log in
|
||||
</button>
|
||||
<button className="rounded-full bg-slate-900 px-4 py-1.5 text-xs text-white">
|
||||
Get Stirling free
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 items-center justify-center px-6 pb-16">
|
||||
<div className="w-full max-w-4xl rounded-[32px] border border-slate-200 bg-white p-10 shadow-lg">
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="text-3xl font-semibold text-slate-900">Stirling PDF</div>
|
||||
<p className="text-sm text-slate-500">Create any PDF you can imagine with AI</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-10">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm">
|
||||
{isRecording ? (
|
||||
<div className="space-y-3">
|
||||
<AudioWaveform history={waveformHistory} />
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{whisperStatus || 'Listening...'}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1"
|
||||
onClick={onCancelRecording}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-blue-600 px-3 py-1 text-white"
|
||||
onClick={onAcceptRecording}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
className="w-full resize-none text-sm text-slate-700 placeholder:text-slate-400 focus:outline-none"
|
||||
rows={1}
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Make an invoice for me to bill a client for $1500 in consulting fees"
|
||||
/>
|
||||
{uploadedPdfFile && (
|
||||
<div className="mt-2 text-xs text-emerald-600">{uploadedPdfFile.name}</div>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<label
|
||||
htmlFor="pdf-upload-landing"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 text-slate-400"
|
||||
>
|
||||
+
|
||||
</label>
|
||||
<input
|
||||
id="pdf-upload-landing"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={(event) => onFileSelect(event.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-500"
|
||||
onClick={onToggleTemplatePanel}
|
||||
>
|
||||
Template
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatDocLabel(selectedDocType)} · {formatDocLabel(selectedTemplateId)}
|
||||
</span>
|
||||
{isTemplatePanelOpen && (
|
||||
<div className="absolute left-2 top-10 w-80 rounded-2xl border border-slate-200 bg-white p-4 shadow-lg z-50">
|
||||
<div className="absolute -top-2 left-6 h-3 w-3 rotate-45 border border-slate-200 bg-white" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 px-3 py-2 text-xs text-slate-500">
|
||||
<span>🔎</span>
|
||||
<input
|
||||
className="w-full bg-transparent text-sm text-slate-600 focus:outline-none"
|
||||
placeholder="Search templates..."
|
||||
value={templateSearch}
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ id: 'popular', label: 'Popular' },
|
||||
{ id: 'legal', label: 'Legal' },
|
||||
{ id: 'financial', label: 'Financial' },
|
||||
{ id: 'academic', label: 'Academic' },
|
||||
{ id: 'marketing', label: 'Marketing' },
|
||||
{ id: 'operations', label: 'Operations' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTemplateTab(tab.id as typeof activeTemplateTab)}
|
||||
className={`rounded-full px-3 py-1 text-xs ${
|
||||
activeTemplateTab === tab.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 max-h-64 overflow-y-auto pr-1 relative">
|
||||
{visibleDocTypes.map((docType) => {
|
||||
const isExpanded = expandedDocType === docType
|
||||
const templates =
|
||||
templateCatalog.find((entry) => entry.docType === docType)?.templates ||
|
||||
(docType === selectedDocType ? templatesForSelected : ['default'])
|
||||
return (
|
||||
<div key={docType} className="rounded-lg border border-slate-200 bg-white">
|
||||
<div
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-sm text-slate-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
onClick={() => onSelectTemplate(docType, 'default')}
|
||||
>
|
||||
<span>{docTypeIcon(docType)}</span>
|
||||
<span>{formatDocLabel(docType)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 px-2"
|
||||
onClick={() => setExpandedDocType(isExpanded ? null : docType)}
|
||||
>
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-200 px-3 py-2 space-y-2">
|
||||
{(templates || ['default']).map((templateId) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${docType}-${templateId}`}
|
||||
onClick={() => onSelectTemplate(docType, templateId)}
|
||||
className={`flex w-full items-center justify-between rounded-md px-2 py-1 text-sm ${
|
||||
selectedDocType === docType && selectedTemplateId === templateId
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onMouseMove={(event) =>
|
||||
setHoveredTemplate({
|
||||
docType,
|
||||
templateId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHoveredTemplate(null)}
|
||||
>
|
||||
<span>{formatDocLabel(templateId)}</span>
|
||||
<span className="text-xs text-slate-400">Select</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!visibleDocTypes.length && (
|
||||
<div className="text-sm text-slate-400">No templates found.</div>
|
||||
)}
|
||||
{hoveredTemplate && (
|
||||
<div
|
||||
className="fixed z-50 w-36 rounded-lg border border-slate-200 bg-white shadow-lg p-2"
|
||||
style={{ left: hoveredTemplate.x + 12, top: hoveredTemplate.y + 12 }}
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-400 mb-2">
|
||||
Preview
|
||||
</div>
|
||||
<div className="w-full bg-slate-50 rounded-md overflow-hidden" style={{ aspectRatio: '210 / 297' }}>
|
||||
<img
|
||||
src={templateThumbnailUrl(hoveredTemplate.docType, hoveredTemplate.templateId)}
|
||||
alt={`${hoveredTemplate.docType} ${hoveredTemplate.templateId} preview`}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 text-slate-500"
|
||||
onClick={onToggleRecording}
|
||||
aria-label="Voice input"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isImporting || (!prompt.trim() && !uploadedPdfFile)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-white disabled:opacity-40"
|
||||
aria-label="Generate"
|
||||
>
|
||||
➜
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
interface ImportLayoutModalProps {
|
||||
isOpen: boolean
|
||||
docType: string
|
||||
onDocTypeChange: (value: string) => void
|
||||
onClose: () => void
|
||||
onFileSelected: (file: File) => void
|
||||
isImporting: boolean
|
||||
status: string | null
|
||||
}
|
||||
|
||||
export function ImportLayoutModal({
|
||||
isOpen,
|
||||
docType,
|
||||
onDocTypeChange,
|
||||
onClose,
|
||||
onFileSelected,
|
||||
isImporting,
|
||||
status,
|
||||
}: ImportLayoutModalProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/70 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-slate-300">Import layout from PDF</div>
|
||||
<div className="text-xs text-slate-500">First 2 pages only, uses vision model</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white text-sm">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-400">Document type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docType}
|
||||
onChange={(e) => onDocTypeChange(e.target.value)}
|
||||
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 text-sm"
|
||||
placeholder="invoice, resume, report..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-slate-400">PDF file</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileSelected(file)
|
||||
}}
|
||||
className="w-full text-sm text-slate-300"
|
||||
disabled={isImporting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="text-xs text-slate-300 bg-slate-800 border border-slate-700 rounded-lg px-3 py-2">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 text-xs text-slate-400">
|
||||
<button onClick={onClose} className="px-3 py-1 rounded-lg border border-slate-700 hover:bg-slate-800">
|
||||
Close
|
||||
</button>
|
||||
{isImporting && <span>Processing...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'
|
||||
import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'
|
||||
|
||||
GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()
|
||||
|
||||
type PageTextItem = {
|
||||
id: string
|
||||
str: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
type PageData = {
|
||||
width: number
|
||||
height: number
|
||||
image: string
|
||||
items: PageTextItem[]
|
||||
}
|
||||
|
||||
interface PdfTextEditorLiteProps {
|
||||
pdfUrl: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PdfTextEditorLite({ pdfUrl, onClose }: PdfTextEditorLiteProps) {
|
||||
const [pages, setPages] = useState<PageData[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editedItems, setEditedItems] = useState<Record<string, string>>({})
|
||||
|
||||
const loadPdf = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const resp = await fetch(pdfUrl)
|
||||
const buffer = await resp.arrayBuffer()
|
||||
const pdf = await getDocument({ data: buffer }).promise
|
||||
const loaded: PageData[] = []
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i += 1) {
|
||||
const page = await pdf.getPage(i)
|
||||
const viewport = page.getViewport({ scale: 1.2 })
|
||||
|
||||
// Render page to image
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) continue
|
||||
canvas.width = viewport.width
|
||||
canvas.height = viewport.height
|
||||
await page.render({ canvasContext: ctx, viewport }).promise
|
||||
const image = canvas.toDataURL('image/png')
|
||||
|
||||
// Extract text items
|
||||
const textContent = await page.getTextContent()
|
||||
const items: PageTextItem[] = textContent.items
|
||||
.map((raw, idx) => {
|
||||
const it = raw as any
|
||||
const transform: number[] = Array.isArray(it?.transform) ? it.transform : [1, 0, 0, 1, 0, 0]
|
||||
const [a, b, , , e, f] = transform
|
||||
const x = typeof e === 'number' ? e : 0
|
||||
const y = typeof f === 'number' ? f : 0
|
||||
const fontSize = Math.hypot(a || 0, b || 0) || it?.height || 12
|
||||
const width = ((it?.width as number | undefined) || fontSize) * viewport.scale
|
||||
const str = typeof it?.str === 'string' ? it.str : ''
|
||||
return { id: `${i}-${idx}`, str, x, y, width, fontSize }
|
||||
})
|
||||
.filter((it) => it.str.length > 0)
|
||||
|
||||
loaded.push({
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
image,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
setPages(loaded)
|
||||
const initialEdits: Record<string, string> = {}
|
||||
loaded.forEach((p) =>
|
||||
p.items.forEach((it) => {
|
||||
initialEdits[it.id] = it.str
|
||||
}),
|
||||
)
|
||||
setEditedItems(initialEdits)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load PDF')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [pdfUrl])
|
||||
|
||||
useEffect(() => {
|
||||
loadPdf()
|
||||
}, [loadPdf])
|
||||
|
||||
const handleChange = (id: string, value: string) => {
|
||||
setEditedItems((prev) => ({ ...prev, [id]: value }))
|
||||
}
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const doc = await PDFDocument.create()
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica)
|
||||
|
||||
for (const page of pages) {
|
||||
const pdfPage = doc.addPage([page.width, page.height])
|
||||
const bg = await doc.embedPng(page.image)
|
||||
pdfPage.drawImage(bg, { x: 0, y: 0, width: page.width, height: page.height })
|
||||
|
||||
page.items.forEach((item) => {
|
||||
const text = editedItems[item.id] ?? item.str
|
||||
const yPdf = page.height - item.y // flip coordinate to bottom-left origin
|
||||
pdfPage.drawText(text, {
|
||||
x: item.x,
|
||||
y: yPdf - item.fontSize,
|
||||
size: item.fontSize || 12,
|
||||
font,
|
||||
color: rgb(0, 0, 0),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const bytes = await doc.save()
|
||||
const arrayBuffer = new ArrayBuffer(bytes.byteLength)
|
||||
new Uint8Array(arrayBuffer).set(bytes)
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'edited.pdf'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to generate PDF')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [editedItems, pages])
|
||||
|
||||
const pageCount = useMemo(() => pages.length, [pages])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-slate-950">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-slate-100 font-semibold">Text Editor (lite)</h3>
|
||||
<span className="text-xs text-slate-400">Pages: {pageCount || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={saving || loading || pages.length === 0}
|
||||
className="px-3 py-1.5 rounded bg-blue-600 text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Download edited PDF'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded bg-slate-700 text-slate-100 text-sm hover:bg-slate-600"
|
||||
>
|
||||
Back to preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="px-4 py-2 text-sm text-amber-200 bg-amber-500/10 border border-amber-600">{error}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-8">
|
||||
{loading && <div className="text-slate-300 text-sm">Loading PDF…</div>}
|
||||
{!loading &&
|
||||
pages.map((page, pageIdx) => (
|
||||
<div
|
||||
key={page.image}
|
||||
className="relative bg-slate-900 border border-slate-800 rounded-lg p-2 shadow"
|
||||
style={{ width: page.width, minHeight: page.height }}
|
||||
>
|
||||
<div className="absolute inset-2">
|
||||
<img
|
||||
src={page.image}
|
||||
alt={`Page ${pageIdx + 1}`}
|
||||
className="w-full h-auto rounded border border-slate-800 shadow pointer-events-none select-none"
|
||||
/>
|
||||
<div className="absolute inset-0">
|
||||
{page.items.map((item) => {
|
||||
const yTop = page.height - item.y
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={(e) => handleChange(item.id, (e.target as HTMLDivElement).innerText)}
|
||||
className="absolute bg-transparent outline-none focus:ring-1 focus:ring-blue-400 rounded px-0.5"
|
||||
style={{
|
||||
left: item.x,
|
||||
top: yTop - item.fontSize * 0.85,
|
||||
minWidth: Math.max(item.width, 4),
|
||||
fontSize: item.fontSize,
|
||||
lineHeight: '1.05',
|
||||
color: '#111827',
|
||||
}}
|
||||
>
|
||||
{editedItems[item.id] ?? item.str}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && pages.length === 0 && <div className="text-slate-400 text-sm">No pages loaded.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PdfTextEditorLite
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'
|
||||
|
||||
GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()
|
||||
|
||||
type ThumbnailPage = {
|
||||
id: string
|
||||
dataUrl: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function PdfPreviewSkeleton() {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<div
|
||||
className="relative rounded-2xl border border-slate-300/60 bg-white shadow-2xl overflow-hidden"
|
||||
style={{
|
||||
width: '860px',
|
||||
maxWidth: '90vw',
|
||||
aspectRatio: '1 / 1.414',
|
||||
}}
|
||||
>
|
||||
<div className="h-12 bg-slate-100 border-b border-slate-200 animate-pulse" />
|
||||
<div className="p-8 space-y-4">
|
||||
{[1, 2, 3, 4].map((line) => (
|
||||
<div
|
||||
key={`line-top-${line}`}
|
||||
className="h-4 rounded-full bg-slate-200 animate-pulse"
|
||||
style={{ width: `${78 - line * 10}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="h-40 rounded-xl bg-slate-100 border border-slate-200 animate-pulse" />
|
||||
{[5, 6, 7].map((line) => (
|
||||
<div
|
||||
key={`line-bottom-${line}`}
|
||||
className="h-4 rounded-full bg-slate-200 animate-pulse"
|
||||
style={{ width: `${70 - (line - 5) * 8}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PdfThumbnailViewerProps {
|
||||
pdfUrl: string
|
||||
isLivePreviewing?: boolean
|
||||
}
|
||||
|
||||
export function PdfThumbnailViewer({ pdfUrl, isLivePreviewing = false }: PdfThumbnailViewerProps) {
|
||||
const [pages, setPages] = useState<ThumbnailPage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [hasRenderedPage, setHasRenderedPage] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfUrl) {
|
||||
setPages([])
|
||||
setHasRenderedPage(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const controller = new AbortController()
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(pdfUrl, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to download PDF preview')
|
||||
}
|
||||
const buffer = await response.arrayBuffer()
|
||||
const pdf = await getDocument({ data: buffer }).promise
|
||||
const next: ThumbnailPage[] = []
|
||||
try {
|
||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
const page = await pdf.getPage(pageNumber)
|
||||
const viewport = page.getViewport({ scale: 0.85 })
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
canvas.width = viewport.width
|
||||
canvas.height = viewport.height
|
||||
await page.render({ canvasContext: ctx, viewport }).promise
|
||||
next.push({
|
||||
id: `page-${pageNumber}`,
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
})
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
page.cleanup?.()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
pdf.cleanup?.()
|
||||
await pdf.destroy?.()
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setPages(next)
|
||||
if (next.length > 0) {
|
||||
setHasRenderedPage(true)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load PDF preview'
|
||||
const normalized = message.toLowerCase()
|
||||
if (
|
||||
isLivePreviewing &&
|
||||
(normalized.includes('zero bytes') || normalized.includes('file is empty') || normalized.includes('pdf is empty'))
|
||||
) {
|
||||
setError(null)
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [pdfUrl, isLivePreviewing])
|
||||
|
||||
const shouldShowSkeleton = (loading && !hasRenderedPage && pages.length === 0) || (isLivePreviewing && pages.length === 0 && !error && !hasRenderedPage)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-slate-900">
|
||||
{error && (
|
||||
<div className="border-b border-amber-700 bg-amber-500/15 px-4 py-3 text-sm text-amber-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="sticky top-0 z-10 mb-4 flex items-center justify-end gap-3 rounded-lg border border-slate-800 bg-slate-850/80 px-3 py-2 backdrop-blur">
|
||||
<span className="text-xs text-slate-300">Zoom</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((z) => Math.max(0.5, Math.round((z - 0.1) * 100) / 100))}
|
||||
className="h-8 w-8 rounded border border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-750"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="h-2 w-32 accent-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((z) => Math.min(2, Math.round((z + 0.1) * 100) / 100))}
|
||||
className="h-8 w-8 rounded border border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-750"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="w-12 text-right text-xs text-slate-300">{Math.round(zoom * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldShowSkeleton && <PdfPreviewSkeleton />}
|
||||
|
||||
{!shouldShowSkeleton && pages.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-400">Preview unavailable.</div>
|
||||
)}
|
||||
|
||||
{!shouldShowSkeleton && pages.length > 0 && (
|
||||
<div className="mx-auto flex max-w-5xl flex-col items-center gap-8">
|
||||
{pages.map((page) => {
|
||||
const baseScale = Math.min(920 / page.width, 1)
|
||||
const displayScale = baseScale * zoom
|
||||
const displayWidth = page.width * displayScale
|
||||
const displayHeight = page.height * displayScale
|
||||
return (
|
||||
<div
|
||||
key={page.id}
|
||||
className="overflow-hidden rounded-lg border border-slate-800 bg-white shadow-2xl"
|
||||
style={{
|
||||
width: displayWidth,
|
||||
height: displayHeight,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={page.dataUrl}
|
||||
alt={`PDF page ${page.id}`}
|
||||
className="block h-full w-full select-none object-contain"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PdfThumbnailViewer
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
|
||||
type ZoomControlsProps = {
|
||||
value: number
|
||||
onChange: (next: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||
|
||||
const formatPercent = (value: number) => `${Math.round(value * 100)}%`
|
||||
|
||||
const ZoomControls: React.FC<ZoomControlsProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
min = 0.2,
|
||||
max = 3,
|
||||
step = 0.1,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const handleChange = (next: number) => {
|
||||
if (disabled) return
|
||||
const clamped = clamp(next, min, max)
|
||||
onChange(Number(clamped.toFixed(2)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<span className="hidden text-xs text-slate-400 sm:inline">Zoom</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChange(value - step)}
|
||||
disabled={disabled}
|
||||
className="rounded border border-slate-700 bg-slate-800 px-2 py-1 text-sm text-slate-200 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div className="min-w-[56px] rounded border border-slate-700 bg-slate-800 px-2 py-1 text-center text-xs font-medium text-slate-100">
|
||||
{formatPercent(value)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChange(value + step)}
|
||||
disabled={disabled}
|
||||
className="rounded border border-slate-700 bg-slate-800 px-2 py-1 text-sm text-slate-200 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ZoomControls
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { PdfJsonDocument, PdfJsonFont } from './pdfTextEditorTypes';
|
||||
|
||||
export type FontStatus = 'perfect' | 'embedded-subset' | 'system-fallback' | 'missing' | 'unknown';
|
||||
|
||||
export interface FontAnalysis {
|
||||
fontId: string;
|
||||
baseName: string;
|
||||
status: FontStatus;
|
||||
embedded: boolean;
|
||||
isSubset: boolean;
|
||||
isStandard14: boolean;
|
||||
hasWebFormat: boolean;
|
||||
webFormat?: string;
|
||||
subtype?: string;
|
||||
encoding?: string;
|
||||
warnings: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export interface DocumentFontAnalysis {
|
||||
fonts: FontAnalysis[];
|
||||
canReproducePerfectly: boolean;
|
||||
hasWarnings: boolean;
|
||||
summary: {
|
||||
perfect: number;
|
||||
embeddedSubset: number;
|
||||
systemFallback: number;
|
||||
missing: number;
|
||||
unknown: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a font name indicates it's a subset font.
|
||||
* Subset fonts typically have a 6-character prefix like "ABCDEE+"
|
||||
*/
|
||||
const isSubsetFont = (baseName: string | null | undefined): boolean => {
|
||||
if (!baseName) return false;
|
||||
// Check for common subset patterns: ABCDEF+FontName
|
||||
return /^[A-Z]{6}\+/.test(baseName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a font is one of the standard 14 PDF fonts that are guaranteed
|
||||
* to be available on all PDF readers
|
||||
*/
|
||||
const isStandard14Font = (font: PdfJsonFont): boolean => {
|
||||
if (font.standard14Name) return true;
|
||||
|
||||
const baseName = (font.baseName || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||
|
||||
const standard14Patterns = [
|
||||
'timesroman', 'timesbold', 'timesitalic', 'timesbolditalic',
|
||||
'helvetica', 'helveticabold', 'helveticaoblique', 'helveticaboldoblique',
|
||||
'courier', 'courierbold', 'courieroblique', 'courierboldoblique',
|
||||
'symbol', 'zapfdingbats'
|
||||
];
|
||||
|
||||
// Check exact matches or if the base name contains the pattern
|
||||
return standard14Patterns.some(pattern => {
|
||||
// Exact match
|
||||
if (baseName === pattern) return true;
|
||||
// Contains pattern (e.g., "ABCDEF+Helvetica" matches "helvetica")
|
||||
if (baseName.includes(pattern)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a font has a fallback available on the backend.
|
||||
* These fonts are embedded in the Stirling PDF backend and can be used
|
||||
* for PDF export even if not in the original PDF.
|
||||
*
|
||||
* Based on PdfJsonFallbackFontService.java
|
||||
*/
|
||||
const hasBackendFallbackFont = (font: PdfJsonFont): boolean => {
|
||||
const baseName = (font.baseName || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||
|
||||
// Backend has these font families available (from PdfJsonFallbackFontService)
|
||||
const backendFonts = [
|
||||
// Liberation fonts (metric-compatible with MS core fonts)
|
||||
'arial', 'helvetica', 'arimo',
|
||||
'times', 'timesnewroman', 'tinos',
|
||||
'courier', 'couriernew', 'cousine',
|
||||
'liberation', 'liberationsans', 'liberationserif', 'liberationmono',
|
||||
// DejaVu fonts
|
||||
'dejavu', 'dejavusans', 'dejavuserif', 'dejavumono', 'dejavusansmono',
|
||||
// Noto fonts
|
||||
'noto', 'notosans'
|
||||
];
|
||||
|
||||
return backendFonts.some(pattern => {
|
||||
if (baseName === pattern) return true;
|
||||
if (baseName.includes(pattern)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the base font name from a subset font name
|
||||
* e.g., "ABCDEF+Arial" -> "Arial"
|
||||
*/
|
||||
const extractBaseFontName = (baseName: string | null | undefined): string | null => {
|
||||
if (!baseName) return null;
|
||||
const match = baseName.match(/^[A-Z]{6}\+(.+)$/);
|
||||
return match ? match[1] : baseName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes a single font to determine if it can be reproduced perfectly
|
||||
* Takes allFonts to check if full versions of subset fonts are available
|
||||
*/
|
||||
export const analyzeFontReproduction = (font: PdfJsonFont, allFonts?: PdfJsonFont[]): FontAnalysis => {
|
||||
const fontId = font.id || font.uid || 'unknown';
|
||||
const baseName = font.baseName || 'Unknown Font';
|
||||
const isSubset = isSubsetFont(font.baseName);
|
||||
const isStandard14 = isStandard14Font(font);
|
||||
const hasBackendFallback = hasBackendFallbackFont(font);
|
||||
const embedded = font.embedded ?? false;
|
||||
|
||||
// Check available web formats (ordered by preference)
|
||||
const webFormats = [
|
||||
{ key: 'webProgram', format: font.webProgramFormat },
|
||||
{ key: 'pdfProgram', format: font.pdfProgramFormat },
|
||||
{ key: 'program', format: font.programFormat },
|
||||
];
|
||||
|
||||
const availableWebFormat = webFormats.find(f => f.format);
|
||||
const hasWebFormat = !!availableWebFormat;
|
||||
const webFormat = availableWebFormat?.format || undefined;
|
||||
|
||||
const warnings: string[] = [];
|
||||
const suggestions: string[] = [];
|
||||
let status: FontStatus = 'unknown';
|
||||
|
||||
// Check if we have the full font when this is a subset
|
||||
let hasFullFontVersion = false;
|
||||
if (isSubset && allFonts) {
|
||||
const baseFont = extractBaseFontName(font.baseName);
|
||||
if (baseFont) {
|
||||
// Look for a non-subset version of this font with a web format
|
||||
hasFullFontVersion = allFonts.some(f => {
|
||||
const otherBaseName = extractBaseFontName(f.baseName);
|
||||
const isNotSubset = !isSubsetFont(f.baseName);
|
||||
const hasFormat = !!(f.webProgramFormat || f.pdfProgramFormat || f.programFormat);
|
||||
const sameBase = otherBaseName?.toLowerCase() === baseFont.toLowerCase();
|
||||
return sameBase && isNotSubset && hasFormat && (f.embedded ?? false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze font status - focusing on PDF export quality
|
||||
if (isStandard14) {
|
||||
// Standard 14 fonts are always available in PDF readers - perfect for export!
|
||||
status = 'perfect';
|
||||
suggestions.push('Standard PDF font (Times, Helvetica, or Courier). Always available in PDF readers.');
|
||||
suggestions.push('Exported PDFs will render consistently across all PDF readers.');
|
||||
} else if (embedded && !isSubset) {
|
||||
// Perfect: Fully embedded with complete character set
|
||||
status = 'perfect';
|
||||
suggestions.push('Font is fully embedded. Exported PDFs will reproduce text perfectly, even with edits.');
|
||||
} else if (embedded && isSubset && (hasFullFontVersion || hasBackendFallback)) {
|
||||
// Subset but we have the full font or backend fallback - perfect!
|
||||
status = 'perfect';
|
||||
if (hasFullFontVersion) {
|
||||
suggestions.push('Full font version is also available in the document. Exported PDFs can reproduce all characters.');
|
||||
} else if (hasBackendFallback) {
|
||||
suggestions.push('Backend has the full font available. Exported PDFs can reproduce all characters, including new text.');
|
||||
}
|
||||
} else if (embedded && isSubset) {
|
||||
// Good, but subset: May have missing characters if user adds new text
|
||||
status = 'embedded-subset';
|
||||
warnings.push('This is a subset font - only specific characters are embedded in the PDF.');
|
||||
warnings.push('Exported PDFs may have missing characters if you add new text with this font.');
|
||||
suggestions.push('Existing text will export correctly. New characters may render as boxes (☐) or fallback glyphs.');
|
||||
} else if (!embedded && hasBackendFallback) {
|
||||
// Not embedded, but backend has it - perfect for export!
|
||||
status = 'perfect';
|
||||
suggestions.push('Backend has this font available. Exported PDFs will use the backend fallback font.');
|
||||
suggestions.push('Text will export correctly with consistent appearance.');
|
||||
} else if (!embedded) {
|
||||
// Not embedded - must rely on system fonts (risky for export)
|
||||
status = 'missing';
|
||||
warnings.push('Font is not embedded in the PDF.');
|
||||
warnings.push('Exported PDFs will substitute with a fallback font, which may look very different.');
|
||||
suggestions.push('Consider re-embedding fonts or accepting that the exported PDF will use fallback fonts.');
|
||||
} else if (embedded && !hasWebFormat) {
|
||||
// Embedded but no web format available (still okay for export)
|
||||
status = 'perfect';
|
||||
suggestions.push('Font is embedded in the PDF. Exported PDFs will reproduce correctly.');
|
||||
suggestions.push('Web preview may use a fallback font, but the final PDF export will be accurate.');
|
||||
}
|
||||
|
||||
// Additional warnings based on font properties
|
||||
if (font.subtype === 'Type0' && font.cidSystemInfo) {
|
||||
const registry = font.cidSystemInfo.registry || '';
|
||||
const ordering = font.cidSystemInfo.ordering || '';
|
||||
if (registry.includes('Adobe') && (ordering.includes('Identity') || ordering.includes('UCS'))) {
|
||||
// CID fonts with Identity encoding are common for Asian languages
|
||||
if (!embedded || !hasWebFormat) {
|
||||
warnings.push('This CID font may contain Asian or Unicode characters.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (font.encoding && !font.encoding.includes('WinAnsiEncoding') && !font.encoding.includes('MacRomanEncoding')) {
|
||||
// Custom encodings may cause issues
|
||||
if (font.encoding !== 'Identity-H' && font.encoding !== 'Identity-V') {
|
||||
warnings.push(`Custom encoding detected: ${font.encoding}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fontId,
|
||||
baseName,
|
||||
status,
|
||||
embedded,
|
||||
isSubset,
|
||||
isStandard14,
|
||||
hasWebFormat,
|
||||
webFormat,
|
||||
subtype: font.subtype || undefined,
|
||||
encoding: font.encoding || undefined,
|
||||
warnings,
|
||||
suggestions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets fonts used on a specific page
|
||||
*/
|
||||
export const getFontsForPage = (
|
||||
document: PdfJsonDocument | null,
|
||||
pageIndex: number
|
||||
): PdfJsonFont[] => {
|
||||
if (!document?.fonts || !document?.pages || pageIndex < 0 || pageIndex >= document.pages.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const page = document.pages[pageIndex];
|
||||
if (!page?.textElements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get unique font IDs used on this page
|
||||
const fontIdsOnPage = new Set<string>();
|
||||
page.textElements.forEach(element => {
|
||||
if (element?.fontId) {
|
||||
fontIdsOnPage.add(element.fontId);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter fonts to only those used on this page
|
||||
const allFonts = document.fonts.filter((font): font is PdfJsonFont => font !== null && font !== undefined);
|
||||
|
||||
const fontsOnPage = allFonts.filter(font => {
|
||||
// Match by ID
|
||||
if (font.id && fontIdsOnPage.has(font.id)) {
|
||||
return true;
|
||||
}
|
||||
// Match by UID
|
||||
if (font.uid && fontIdsOnPage.has(font.uid)) {
|
||||
return true;
|
||||
}
|
||||
// Match by page-specific ID (pageNumber:id format)
|
||||
if (font.pageNumber === pageIndex + 1 && font.id) {
|
||||
const pageSpecificId = `${font.pageNumber}:${font.id}`;
|
||||
if (fontIdsOnPage.has(pageSpecificId) || fontIdsOnPage.has(font.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Deduplicate by base font name to avoid showing the same font multiple times
|
||||
const uniqueFonts = new Map<string, PdfJsonFont>();
|
||||
fontsOnPage.forEach(font => {
|
||||
const baseName = extractBaseFontName(font.baseName) || font.baseName || font.id || 'unknown';
|
||||
const key = baseName.toLowerCase();
|
||||
|
||||
// Keep the first occurrence, or prefer non-subset over subset
|
||||
const existing = uniqueFonts.get(key);
|
||||
if (!existing) {
|
||||
uniqueFonts.set(key, font);
|
||||
} else {
|
||||
// Prefer non-subset fonts over subset fonts
|
||||
const existingIsSubset = isSubsetFont(existing.baseName);
|
||||
const currentIsSubset = isSubsetFont(font.baseName);
|
||||
if (existingIsSubset && !currentIsSubset) {
|
||||
uniqueFonts.set(key, font);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueFonts.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes all fonts in a PDF document (or just fonts for a specific page)
|
||||
*/
|
||||
export const analyzeDocumentFonts = (
|
||||
document: PdfJsonDocument | null,
|
||||
pageIndex?: number
|
||||
): DocumentFontAnalysis => {
|
||||
if (!document?.fonts || document.fonts.length === 0) {
|
||||
return {
|
||||
fonts: [],
|
||||
canReproducePerfectly: true,
|
||||
hasWarnings: false,
|
||||
summary: {
|
||||
perfect: 0,
|
||||
embeddedSubset: 0,
|
||||
systemFallback: 0,
|
||||
missing: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const allFonts = document.fonts.filter((font): font is PdfJsonFont => font !== null && font !== undefined);
|
||||
|
||||
// Filter to page-specific fonts if pageIndex is provided
|
||||
const fontsToAnalyze = pageIndex !== undefined
|
||||
? getFontsForPage(document, pageIndex)
|
||||
: allFonts;
|
||||
|
||||
if (fontsToAnalyze.length === 0) {
|
||||
return {
|
||||
fonts: [],
|
||||
canReproducePerfectly: true,
|
||||
hasWarnings: false,
|
||||
summary: {
|
||||
perfect: 0,
|
||||
embeddedSubset: 0,
|
||||
systemFallback: 0,
|
||||
missing: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fontAnalyses = fontsToAnalyze.map(font => analyzeFontReproduction(font, allFonts));
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
perfect: fontAnalyses.filter(f => f.status === 'perfect').length,
|
||||
embeddedSubset: fontAnalyses.filter(f => f.status === 'embedded-subset').length,
|
||||
systemFallback: fontAnalyses.filter(f => f.status === 'system-fallback').length,
|
||||
missing: fontAnalyses.filter(f => f.status === 'missing').length,
|
||||
unknown: fontAnalyses.filter(f => f.status === 'unknown').length,
|
||||
};
|
||||
|
||||
// Can reproduce perfectly ONLY if all fonts are truly perfect (not subsets)
|
||||
const canReproducePerfectly = fontAnalyses.every(f => f.status === 'perfect');
|
||||
|
||||
// Has warnings if any font has issues (including subsets)
|
||||
const hasWarnings = fontAnalyses.some(
|
||||
f => f.warnings.length > 0 || f.status === 'missing' || f.status === 'system-fallback' || f.status === 'embedded-subset'
|
||||
);
|
||||
|
||||
return {
|
||||
fonts: fontAnalyses,
|
||||
canReproducePerfectly,
|
||||
hasWarnings,
|
||||
summary,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a human-readable description of the font status
|
||||
*/
|
||||
export const getFontStatusDescription = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return 'Fully embedded - perfect reproduction';
|
||||
case 'embedded-subset':
|
||||
return 'Embedded (subset) - existing text will render correctly';
|
||||
case 'system-fallback':
|
||||
return 'Using system font - appearance may differ';
|
||||
case 'missing':
|
||||
return 'Not embedded - will use fallback font';
|
||||
case 'unknown':
|
||||
return 'Unknown status';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a color indicator for the font status
|
||||
*/
|
||||
export const getFontStatusColor = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return 'green';
|
||||
case 'embedded-subset':
|
||||
return 'blue';
|
||||
case 'system-fallback':
|
||||
return 'yellow';
|
||||
case 'missing':
|
||||
return 'red';
|
||||
case 'unknown':
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an icon indicator for the font status
|
||||
*/
|
||||
export const getFontStatusIcon = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return '✓';
|
||||
case 'embedded-subset':
|
||||
return '⚠';
|
||||
case 'system-fallback':
|
||||
return '⚠';
|
||||
case 'missing':
|
||||
return '✗';
|
||||
case 'unknown':
|
||||
return '?';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,232 @@
|
||||
export interface PdfJsonFontCidSystemInfo {
|
||||
registry?: string | null;
|
||||
ordering?: string | null;
|
||||
supplement?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonTextColor {
|
||||
colorSpace?: string | null;
|
||||
components?: number[] | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonCosValue {
|
||||
type?: string | null;
|
||||
value?: unknown;
|
||||
items?: PdfJsonCosValue[] | null;
|
||||
entries?: Record<string, PdfJsonCosValue | null> | null;
|
||||
stream?: PdfJsonStream | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonFont {
|
||||
id?: string;
|
||||
pageNumber?: number | null;
|
||||
uid?: string | null;
|
||||
baseName?: string | null;
|
||||
color?: string | null;
|
||||
subtype?: string | null;
|
||||
encoding?: string | null;
|
||||
cidSystemInfo?: PdfJsonFontCidSystemInfo | null;
|
||||
embedded?: boolean | null;
|
||||
program?: string | null;
|
||||
programFormat?: string | null;
|
||||
webProgram?: string | null;
|
||||
webProgramFormat?: string | null;
|
||||
pdfProgram?: string | null;
|
||||
pdfProgramFormat?: string | null;
|
||||
toUnicode?: string | null;
|
||||
standard14Name?: string | null;
|
||||
fontDescriptorFlags?: number | null;
|
||||
ascent?: number | null;
|
||||
descent?: number | null;
|
||||
capHeight?: number | null;
|
||||
xHeight?: number | null;
|
||||
italicAngle?: number | null;
|
||||
unitsPerEm?: number | null;
|
||||
cosDictionary?: PdfJsonCosValue | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonTextElement {
|
||||
text?: string | null;
|
||||
fontId?: string | null;
|
||||
fontSize?: number | null;
|
||||
fontMatrixSize?: number | null;
|
||||
fontSizeInPt?: number | null;
|
||||
characterSpacing?: number | null;
|
||||
wordSpacing?: number | null;
|
||||
spaceWidth?: number | null;
|
||||
zOrder?: number | null;
|
||||
horizontalScaling?: number | null;
|
||||
leading?: number | null;
|
||||
rise?: number | null;
|
||||
renderingMode?: number | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
textMatrix?: number[] | null;
|
||||
fillColor?: PdfJsonTextColor | null;
|
||||
strokeColor?: PdfJsonTextColor | null;
|
||||
charCodes?: number[] | null;
|
||||
fallbackUsed?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonImageElement {
|
||||
id?: string | null;
|
||||
objectName?: string | null;
|
||||
inlineImage?: boolean | null;
|
||||
nativeWidth?: number | null;
|
||||
nativeHeight?: number | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
left?: number | null;
|
||||
right?: number | null;
|
||||
top?: number | null;
|
||||
bottom?: number | null;
|
||||
transform?: number[] | null;
|
||||
zOrder?: number | null;
|
||||
imageData?: string | null;
|
||||
imageFormat?: string | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonStream {
|
||||
dictionary?: Record<string, unknown> | null;
|
||||
rawData?: string | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonPage {
|
||||
pageNumber?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
rotation?: number | null;
|
||||
mediaBox?: number[] | null;
|
||||
cropBox?: number[] | null;
|
||||
textElements?: PdfJsonTextElement[] | null;
|
||||
imageElements?: PdfJsonImageElement[] | null;
|
||||
resources?: unknown;
|
||||
contentStreams?: PdfJsonStream[] | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonMetadata {
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
subject?: string | null;
|
||||
keywords?: string | null;
|
||||
creator?: string | null;
|
||||
producer?: string | null;
|
||||
creationDate?: string | null;
|
||||
modificationDate?: string | null;
|
||||
trapped?: string | null;
|
||||
numberOfPages?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonDocument {
|
||||
metadata?: PdfJsonMetadata | null;
|
||||
xmpMetadata?: string | null;
|
||||
fonts?: PdfJsonFont[] | null;
|
||||
pages?: PdfJsonPage[] | null;
|
||||
lazyImages?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonPageDimension {
|
||||
pageNumber?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
rotation?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonDocumentMetadata {
|
||||
metadata?: PdfJsonMetadata | null;
|
||||
xmpMetadata?: string | null;
|
||||
fonts?: PdfJsonFont[] | null;
|
||||
pageDimensions?: PdfJsonPageDimension[] | null;
|
||||
formFields?: unknown[] | null;
|
||||
lazyImages?: boolean | null;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface TextGroup {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
fontId?: string | null;
|
||||
fontSize?: number | null;
|
||||
fontMatrixSize?: number | null;
|
||||
lineSpacing?: number | null;
|
||||
lineElementCounts?: number[] | null;
|
||||
color?: string | null;
|
||||
fontWeight?: number | 'normal' | 'bold' | null;
|
||||
rotation?: number | null;
|
||||
anchor?: { x: number; y: number } | null;
|
||||
baselineLength?: number | null;
|
||||
baseline?: number | null;
|
||||
elements: PdfJsonTextElement[];
|
||||
originalElements: PdfJsonTextElement[];
|
||||
text: string;
|
||||
originalText: string;
|
||||
bounds: BoundingBox;
|
||||
childLineGroups?: TextGroup[] | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_WIDTH = 612;
|
||||
export const DEFAULT_PAGE_HEIGHT = 792;
|
||||
|
||||
export interface ConversionProgress {
|
||||
percent: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface PdfTextEditorViewData {
|
||||
document: PdfJsonDocument | null;
|
||||
groupsByPage: TextGroup[][];
|
||||
imagesByPage: PdfJsonImageElement[][];
|
||||
pagePreviews: Map<number, string>;
|
||||
selectedPage: number;
|
||||
dirtyPages: boolean[];
|
||||
hasDocument: boolean;
|
||||
hasVectorPreview: boolean;
|
||||
fileName: string;
|
||||
errorMessage: string | null;
|
||||
isGeneratingPdf: boolean;
|
||||
isConverting: boolean;
|
||||
conversionProgress: ConversionProgress | null;
|
||||
hasChanges: boolean;
|
||||
forceSingleTextElement: boolean;
|
||||
groupingMode: 'auto' | 'paragraph' | 'singleLine';
|
||||
requestPagePreview: (pageIndex: number, scale: number) => void;
|
||||
onSelectPage: (pageIndex: number) => void;
|
||||
onGroupEdit: (pageIndex: number, groupId: string, value: string) => void;
|
||||
onGroupDelete: (pageIndex: number, groupId: string) => void;
|
||||
onImageTransform: (
|
||||
pageIndex: number,
|
||||
imageId: string,
|
||||
next: {
|
||||
left: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
transform: number[];
|
||||
},
|
||||
) => void;
|
||||
onImageReset: (pageIndex: number, imageId: string) => void;
|
||||
onReset: () => void;
|
||||
onDownloadJson: () => void;
|
||||
onGeneratePdf: () => void;
|
||||
onGeneratePdfForNavigation: () => Promise<void>;
|
||||
onSaveToWorkbench: () => Promise<void>;
|
||||
isSavingToWorkbench: boolean;
|
||||
onForceSingleTextElementChange: (value: boolean) => void;
|
||||
onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void;
|
||||
onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean;
|
||||
onUngroupGroup: (pageIndex: number, groupId: string) => boolean;
|
||||
onLoadFile: (file: File) => void;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
|
||||
interface AudioWaveformProps {
|
||||
waveformHistory: number[][]
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export function AudioWaveform({ waveformHistory, isActive }: AudioWaveformProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [maxColumns, setMaxColumns] = useState(150)
|
||||
|
||||
// Measure container and calculate how many columns fit
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const updateMaxColumns = () => {
|
||||
if (!containerRef.current) return
|
||||
const width = containerRef.current.offsetWidth
|
||||
// Each column is 2px + 2px gap = 4px effective width
|
||||
const cols = Math.floor(width / 4)
|
||||
setMaxColumns(Math.max(cols, 50)) // minimum 50 columns
|
||||
}
|
||||
|
||||
updateMaxColumns()
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateMaxColumns)
|
||||
resizeObserver.observe(containerRef.current)
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
if (!isActive) return null
|
||||
|
||||
// Use history if available, otherwise show placeholder
|
||||
const columns = waveformHistory || []
|
||||
|
||||
// Only take exactly what fits
|
||||
const visibleColumns = columns.slice(-maxColumns)
|
||||
|
||||
// Pad with empty columns at the start if we don't have enough history
|
||||
const paddingCount = Math.max(0, maxColumns - visibleColumns.length)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex items-center h-6 w-full">
|
||||
<div className="flex items-center gap-0.5 w-full">
|
||||
{/* Padding columns (empty/minimal) */}
|
||||
{Array(paddingCount).fill(0).map((_, i) => (
|
||||
<div
|
||||
key={`pad-${i}`}
|
||||
className="flex flex-col items-center justify-center gap-0.5 flex-shrink-0"
|
||||
style={{ width: '2px', minWidth: '2px' }}
|
||||
>
|
||||
{Array(10).fill(0).map((_, bandIndex) => (
|
||||
<div
|
||||
key={bandIndex}
|
||||
className="w-full bg-blue-400/20 rounded-full"
|
||||
style={{ height: '1px', minHeight: '1px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Actual waveform columns */}
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
const numBands = 10
|
||||
const step = Math.floor(column.length / numBands)
|
||||
const bands = []
|
||||
for (let i = 0; i < numBands; i++) {
|
||||
const index = Math.min(i * step, column.length - 1)
|
||||
bands.push(column[index])
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`col-${colIndex}`}
|
||||
className="flex flex-col items-center justify-center gap-0.5 flex-shrink-0"
|
||||
style={{ width: '2px', minWidth: '2px' }}
|
||||
>
|
||||
{bands.map((level, bandIndex) => {
|
||||
const height = Math.max(1, level * 20)
|
||||
return (
|
||||
<div
|
||||
key={bandIndex}
|
||||
className="w-full bg-blue-400 rounded-full"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
minHeight: '1px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'accent'
|
||||
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
}
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 focus-visible:ring-offset-slate-950 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-500 text-white',
|
||||
secondary: 'bg-slate-800 hover:bg-slate-700 text-slate-100 border border-slate-700',
|
||||
ghost: 'bg-transparent hover:bg-slate-900 text-slate-300 border border-transparent',
|
||||
danger: 'bg-rose-600 hover:bg-rose-500 text-white',
|
||||
accent: 'bg-emerald-600 hover:bg-emerald-500 text-white',
|
||||
}
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'text-xs px-3 py-1.5',
|
||||
md: 'text-sm px-4 py-2',
|
||||
lg: 'text-base px-5 py-3',
|
||||
icon: 'p-2',
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
|
||||
const classes = [base, variantStyles[variant], sizeStyles[size], className].filter(Boolean).join(' ')
|
||||
return <button className={classes} {...props} />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface ButtonGroupProps extends PropsWithChildren {
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}
|
||||
|
||||
export function ButtonGroup({ children, align = 'start' }: ButtonGroupProps) {
|
||||
const alignment =
|
||||
align === 'center' ? 'justify-center' : align === 'end' ? 'justify-end' : 'justify-start'
|
||||
return <div className={`flex flex-wrap gap-2 ${alignment}`}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { FormEvent, KeyboardEvent, MutableRefObject, useRef, useEffect } from 'react'
|
||||
import { DocumentState, Message, StyleProfile } from '../../types'
|
||||
import { Button } from '../ui/Button'
|
||||
import { AudioWaveform } from '../ui/AudioWaveform'
|
||||
|
||||
interface ChatPanelProps {
|
||||
onBack: () => void
|
||||
styleProfile: StyleProfile | null
|
||||
messages: Message[]
|
||||
chatEndRef: MutableRefObject<HTMLDivElement | null>
|
||||
isGenerating: boolean
|
||||
prompt: string
|
||||
onPromptChange: (value: string) => void
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
skipTemplates: boolean
|
||||
onSkipTemplatesChange: (value: boolean) => void
|
||||
onClearSession: () => void
|
||||
onToggleRecording: () => void
|
||||
onCancelRecording: () => void
|
||||
onAcceptRecording: () => void
|
||||
isRecording: boolean
|
||||
whisperStatus: string | null
|
||||
waveformHistory: number[][]
|
||||
currentDoc: DocumentState | null
|
||||
onOpenHistory: () => void
|
||||
onOpenImport: () => void
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
onBack,
|
||||
styleProfile,
|
||||
messages,
|
||||
chatEndRef,
|
||||
isGenerating,
|
||||
prompt,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
skipTemplates,
|
||||
onSkipTemplatesChange,
|
||||
onClearSession,
|
||||
onToggleRecording,
|
||||
onCancelRecording,
|
||||
onAcceptRecording,
|
||||
isRecording,
|
||||
whisperStatus,
|
||||
waveformHistory,
|
||||
currentDoc,
|
||||
onOpenHistory,
|
||||
onOpenImport,
|
||||
}: ChatPanelProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const gradientRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const updateInputGradient = () => {
|
||||
const gradient = gradientRef.current
|
||||
if (!gradient) return
|
||||
// Hide the gradient entirely to avoid lingering blur while typing.
|
||||
gradient.style.opacity = '0'
|
||||
}
|
||||
|
||||
// Auto-resize textarea and update gradient visibility
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200) // Max ~8 lines for the smaller panel
|
||||
textarea.style.height = `${newHeight}px`
|
||||
}
|
||||
updateInputGradient()
|
||||
}, [prompt])
|
||||
|
||||
return (
|
||||
<div className="w-1/3 min-w-[350px] flex flex-col border-r border-slate-800 bg-slate-900 z-10">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
← Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onOpenHistory}>
|
||||
History & Layouts
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" onClick={onOpenImport}>
|
||||
Import Layout (PDF)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{styleProfile && (
|
||||
<div className="px-4 py-2 border-b border-slate-800 flex items-center gap-3 text-xs text-slate-400">
|
||||
<span className="px-2 py-1 rounded-full bg-slate-800 border border-slate-700">
|
||||
{styleProfile.layout_preference || 'clean'}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-full bg-slate-800 border border-slate-700">
|
||||
{styleProfile.font_preference || 'font'}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-full bg-slate-800 border border-slate-700">
|
||||
{styleProfile.tone || 'tone'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 relative">
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl p-4 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-blue-600 text-white rounded-br-none'
|
||||
: 'bg-slate-800 text-slate-200 rounded-bl-none border border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed" style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{msg.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isGenerating && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-slate-800 rounded-2xl p-4 rounded-bl-none border border-slate-700">
|
||||
<div className="flex space-x-2">
|
||||
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-100"></div>
|
||||
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!messages.length && (
|
||||
<div className="text-xs text-slate-500">
|
||||
Ask for revisions, upload layouts, or describe the style you want. I'll keep the latest
|
||||
context.
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-900 border-t border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipTemplates}
|
||||
onChange={(e) => onSkipTemplatesChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-600 bg-slate-800 accent-blue-500"
|
||||
/>
|
||||
<span>Don't reuse saved layouts/templates</span>
|
||||
</label>
|
||||
<Button variant="secondary" size="sm" onClick={onClearSession}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="relative">
|
||||
<div className="w-full bg-slate-800 rounded-xl border border-slate-700 focus-within:ring-2 focus-within:ring-blue-500/50">
|
||||
{/* Content area */}
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
{isRecording ? (
|
||||
<div className="min-h-[20px]">
|
||||
<AudioWaveform waveformHistory={waveformHistory} isActive={isRecording} />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onScroll={updateInputGradient}
|
||||
placeholder={currentDoc ? 'Modify the document...' : 'Describe what to build...'}
|
||||
className="w-full bg-transparent text-white focus:outline-none resize-none overflow-y-auto text-sm"
|
||||
style={{ maxHeight: '200px' }}
|
||||
rows={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={gradientRef}
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-slate-900/90 to-transparent transition-opacity duration-200"
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
{isRecording ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelRecording}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
title="Cancel recording"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onAcceptRecording()
|
||||
}}
|
||||
className="p-2 rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
title="Accept and transcribe"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleRecording}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
title="Start voice input"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isGenerating}
|
||||
className="p-2 rounded-full bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Send"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
||||
<div className="relative group px-3 py-2 rounded-full bg-slate-800 border border-slate-700 font-medium text-slate-300 cursor-not-allowed select-none">
|
||||
<span className="flex items-center gap-2">
|
||||
Document type (auto)
|
||||
<svg className="w-3.5 h-3.5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 15l-7-7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-slate-800 px-3 py-1 text-[11px] text-slate-200 border border-slate-700 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
|
||||
We’ll pick the best document type for your prompt automatically using AI.
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2 rounded-full bg-slate-800 border border-slate-700 font-medium text-slate-300 cursor-not-allowed select-none">
|
||||
GPT 5.1
|
||||
</div>
|
||||
</div>
|
||||
{whisperStatus && <div className="text-[11px] text-slate-400 mt-1">{whisperStatus}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||