mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
# Description of Changes This PR modernizes the project's Python tooling across GitHub Actions by migrating CI workflows from pip-based dependency management to `uv` and aligning Python execution with the engine project's managed environment. ### What was changed - Replaced `actions/setup-python` and ad-hoc `pip install` steps with `astral-sh/setup-uv` across CI workflows. - Configured shared `uv` dependency caching using `engine/pyproject.toml` and `engine/uv.lock`. - Updated Python script execution to use `uv run --project engine --locked` for a consistent runtime environment. - Replaced package installation steps with `uv sync` for the required dependency groups (e.g. `tools` and `cucumber`). - Added Docker image build validation for both production and development AI engine images. - Updated workflow cache configuration and Docker build context where required. - Removed obsolete Python requirements files that are no longer needed after the migration. - Applied minor Python code modernizations, including import cleanup, modern built-in generic type annotations (`list[...]`, `tuple[...]`, `float | None`), and small style improvements. - Removed unnecessary Python formatter/linter extensions from the development container configuration. ### Why the change was made - Standardize Python dependency management across the repository. - Reduce duplicated dependency installation logic in CI. - Improve workflow performance through shared dependency caching. - Ensure all Python utilities execute against the same locked dependency set managed by the engine project. - Simplify long-term maintenance by eliminating legacy requirements files and pip-specific workflow steps. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Signed-off-by: Carsten Drewes <c.drewes@stud.uni-hannover.de> Co-authored-by: albanobattistella <34811668+albanobattistella@users.noreply.github.com> Co-authored-by: kastenherri <116314318+kastenherri@users.noreply.github.com> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: James Brunton <jbrunton96@gmail.com>
268 lines
8.5 KiB
Python
268 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Quick inspection utility for PDF→JSON exports.
|
|
|
|
Usage:
|
|
python scripts/analyze_pdf_json.py path/to/export.json
|
|
|
|
The script prints size and font statistics so we can confirm whether the
|
|
lightweight export (no COS dictionaries) is active and how large the font
|
|
payloads are.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def human_bytes(value: float) -> str:
|
|
if value <= 0:
|
|
return "0 B"
|
|
units = ["B", "KB", "MB", "GB", "TB"]
|
|
order = min(int(math.log(value, 1024)), len(units) - 1)
|
|
scaled = value / (1024**order)
|
|
return f"{scaled:.1f} {units[order]}"
|
|
|
|
|
|
def base64_payload_size(encoded: str | None) -> int:
|
|
if not encoded:
|
|
return 0
|
|
length = len(encoded.strip())
|
|
if length == 0:
|
|
return 0
|
|
return int(length * 0.75)
|
|
|
|
|
|
@dataclass
|
|
class FontBreakdown:
|
|
total: int = 0
|
|
with_cos: int = 0
|
|
with_program: int = 0
|
|
with_web_program: int = 0
|
|
with_pdf_program: int = 0
|
|
program_bytes: int = 0
|
|
web_program_bytes: int = 0
|
|
pdf_program_bytes: int = 0
|
|
metadata_bytes: int = 0
|
|
sample_cos_ids: list[tuple[str | None, str | None]] = None
|
|
|
|
|
|
@dataclass
|
|
class PageBreakdown:
|
|
page_count: int = 0
|
|
total_text_elements: int = 0
|
|
total_image_elements: int = 0
|
|
text_payload_chars: int = 0
|
|
text_struct_bytes: int = 0
|
|
image_struct_bytes: int = 0
|
|
resources_bytes: int = 0
|
|
content_stream_bytes: int = 0
|
|
annotations_bytes: int = 0
|
|
|
|
|
|
@dataclass
|
|
class DocumentBreakdown:
|
|
total_bytes: int
|
|
fonts: FontBreakdown
|
|
pages: PageBreakdown
|
|
metadata_bytes: int
|
|
xmp_bytes: int
|
|
form_fields_bytes: int
|
|
lazy_flag_bytes: int
|
|
|
|
|
|
def approx_struct_size(obj: Any) -> int:
|
|
if obj is None:
|
|
return 0
|
|
return len(json.dumps(obj, separators=(",", ":")))
|
|
|
|
|
|
def analyze_fonts(fonts: Iterable[dict[str, Any]]) -> FontBreakdown:
|
|
total = 0
|
|
with_cos = 0
|
|
with_prog = 0
|
|
with_web_prog = 0
|
|
with_pdf_prog = 0
|
|
program_bytes = 0
|
|
web_program_bytes = 0
|
|
pdf_program_bytes = 0
|
|
metadata_bytes = 0
|
|
sample_cos_ids: list[tuple[str | None, str | None]] = []
|
|
|
|
for font in fonts:
|
|
total += 1
|
|
font_id = font.get("id")
|
|
uid = font.get("uid")
|
|
cos_value = font.get("cosDictionary")
|
|
if cos_value:
|
|
with_cos += 1
|
|
if len(sample_cos_ids) < 5:
|
|
sample_cos_ids.append((font_id, uid))
|
|
|
|
metadata_bytes += approx_struct_size(
|
|
{k: v for k, v in font.items() if k not in {"program", "webProgram", "pdfProgram"}}
|
|
)
|
|
|
|
program = font.get("program")
|
|
web_program = font.get("webProgram")
|
|
pdf_program = font.get("pdfProgram")
|
|
|
|
if program:
|
|
with_prog += 1
|
|
program_bytes += base64_payload_size(program)
|
|
if web_program:
|
|
with_web_prog += 1
|
|
web_program_bytes += base64_payload_size(web_program)
|
|
if pdf_program:
|
|
with_pdf_prog += 1
|
|
pdf_program_bytes += base64_payload_size(pdf_program)
|
|
|
|
return FontBreakdown(
|
|
total=total,
|
|
with_cos=with_cos,
|
|
with_program=with_prog,
|
|
with_web_program=with_web_prog,
|
|
with_pdf_program=with_pdf_prog,
|
|
program_bytes=program_bytes,
|
|
web_program_bytes=web_program_bytes,
|
|
pdf_program_bytes=pdf_program_bytes,
|
|
metadata_bytes=metadata_bytes,
|
|
sample_cos_ids=sample_cos_ids,
|
|
)
|
|
|
|
|
|
def analyze_pages(pages: Iterable[dict[str, Any]]) -> PageBreakdown:
|
|
page_count = 0
|
|
total_text = 0
|
|
total_images = 0
|
|
text_chars = 0
|
|
text_struct_bytes = 0
|
|
image_struct_bytes = 0
|
|
resources_bytes = 0
|
|
stream_bytes = 0
|
|
annotations_bytes = 0
|
|
|
|
for page in pages:
|
|
page_count += 1
|
|
texts = page.get("textElements") or []
|
|
images = page.get("imageElements") or []
|
|
resources = page.get("resources")
|
|
streams = page.get("contentStreams") or []
|
|
annotations = page.get("annotations") or []
|
|
|
|
total_text += len(texts)
|
|
total_images += len(images)
|
|
text_struct_bytes += approx_struct_size(texts)
|
|
image_struct_bytes += approx_struct_size(images)
|
|
resources_bytes += approx_struct_size(resources)
|
|
stream_bytes += approx_struct_size(streams)
|
|
annotations_bytes += approx_struct_size(annotations)
|
|
|
|
for elem in texts:
|
|
text = elem.get("text")
|
|
if text:
|
|
text_chars += len(text)
|
|
|
|
return PageBreakdown(
|
|
page_count=page_count,
|
|
total_text_elements=total_text,
|
|
total_image_elements=total_images,
|
|
text_payload_chars=text_chars,
|
|
text_struct_bytes=text_struct_bytes,
|
|
image_struct_bytes=image_struct_bytes,
|
|
resources_bytes=resources_bytes,
|
|
content_stream_bytes=stream_bytes,
|
|
annotations_bytes=annotations_bytes,
|
|
)
|
|
|
|
|
|
def analyze_document(document: dict[str, Any], total_size: int) -> DocumentBreakdown:
|
|
fonts = document.get("fonts") or []
|
|
pages = document.get("pages") or []
|
|
metadata = document.get("metadata") or {}
|
|
|
|
font_stats = analyze_fonts(fonts)
|
|
page_stats = analyze_pages(pages)
|
|
|
|
return DocumentBreakdown(
|
|
total_bytes=total_size,
|
|
fonts=font_stats,
|
|
pages=page_stats,
|
|
metadata_bytes=approx_struct_size(metadata),
|
|
xmp_bytes=base64_payload_size(document.get("xmpMetadata")),
|
|
form_fields_bytes=approx_struct_size(document.get("formFields")),
|
|
lazy_flag_bytes=approx_struct_size(document.get("lazyImages")),
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Inspect a PDF JSON export.")
|
|
parser.add_argument("json_path", type=Path, help="Path to the JSON export.")
|
|
args = parser.parse_args()
|
|
|
|
json_path = args.json_path
|
|
if not json_path.exists():
|
|
raise SystemExit(f"File not found: {json_path}")
|
|
|
|
file_size = json_path.stat().st_size
|
|
print(f"File: {json_path}")
|
|
print(f"Size: {human_bytes(file_size)} ({file_size:,} bytes)")
|
|
|
|
with json_path.open("r", encoding="utf-8") as handle:
|
|
document = json.load(handle)
|
|
|
|
if not isinstance(document, dict):
|
|
raise SystemExit("Unexpected JSON structure (expected an object at root).")
|
|
|
|
summary = analyze_document(document, file_size)
|
|
page_stats = summary.pages
|
|
print(f"Pages: {page_stats.page_count}")
|
|
print(f"Total text elements: {page_stats.total_text_elements:,}")
|
|
print(f"Total image elements: {page_stats.total_image_elements:,}")
|
|
print(
|
|
f"Page structural bytes (text arrays + images + streams + annotations): "
|
|
f"{human_bytes(page_stats.text_struct_bytes + page_stats.image_struct_bytes + page_stats.content_stream_bytes + page_stats.annotations_bytes)}"
|
|
)
|
|
|
|
font_stats = summary.fonts
|
|
print("\nFont summary:")
|
|
print(f" Fonts total: {font_stats.total}")
|
|
print(f" Fonts with cosDictionary: {font_stats.with_cos}")
|
|
print(f" Fonts with program: {font_stats.with_program}")
|
|
print(f" Fonts with webProgram: {font_stats.with_web_program}")
|
|
print(f" Fonts with pdfProgram: {font_stats.with_pdf_program}")
|
|
print(
|
|
" Payload sizes:"
|
|
f" program={human_bytes(font_stats.program_bytes)},"
|
|
f" webProgram={human_bytes(font_stats.web_program_bytes)},"
|
|
f" pdfProgram={human_bytes(font_stats.pdf_program_bytes)},"
|
|
f" metadata={human_bytes(font_stats.metadata_bytes)}"
|
|
)
|
|
if font_stats.sample_cos_ids:
|
|
print(" Sample fonts still carrying cosDictionary:")
|
|
for idx, (font_id, uid) in enumerate(font_stats.sample_cos_ids, start=1):
|
|
print(f" {idx}. id={font_id!r}, uid={uid!r}")
|
|
else:
|
|
print(" No fonts retain cosDictionary entries.")
|
|
|
|
print("\nOther sections:")
|
|
print(f" Metadata bytes: {human_bytes(summary.metadata_bytes)}")
|
|
print(f" XMP metadata bytes: {human_bytes(summary.xmp_bytes)}")
|
|
print(f" Form fields bytes: {human_bytes(summary.form_fields_bytes)}")
|
|
print(f" Lazy flag bytes: {summary.lazy_flag_bytes}")
|
|
print(f" Text payload characters (not counting JSON overhead): {page_stats.text_payload_chars:,}")
|
|
print(f" Approx text structure bytes: {human_bytes(page_stats.text_struct_bytes)}")
|
|
print(f" Approx image structure bytes: {human_bytes(page_stats.image_struct_bytes)}")
|
|
print(f" Approx content stream bytes: {human_bytes(page_stats.content_stream_bytes)}")
|
|
print(f" Approx annotations bytes: {human_bytes(page_stats.annotations_bytes)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|