Files
Stirling-PDF/scripts/analyze_pdf_json.py
05eb74022a chore(ci): migrate Python tooling to uv and standardize workflow execution (#7386)
# 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>
2026-08-11 08:09:21 +00:00

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