mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
PDF Text editor (#4724)
## Summary - add a `PdfJsonConversionService` that serializes PDF text, fonts, and metadata to JSON and rebuilds a PDF from the same structure - expose REST endpoints for `/pdf/json` and `/json/pdf` conversions using the existing convert API infrastructure - define JSON model classes capturing document metadata, font information, and positioned text elements ## Testing - `./gradlew spotlessApply` *(fails: plugin org.springframework.boot:3.5.4 unavailable in build environment)* - `./gradlew build` *(fails: plugin org.springframework.boot:3.5.4 unavailable in build environment)* ------ https://chatgpt.com/codex/tasks/task_b_68f8e98d94ac8328a0e499e541528b6f --------- Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
co-authored by
EthanHealy01
parent
d42065e338
commit
b0397da19e
@@ -0,0 +1,277 @@
|
||||
#!/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 base64
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
|
||||
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): "
|
||||
f"{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()
|
||||
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wrap raw CFF/Type1C data (extracted from PDFs) as OpenType-CFF for web compatibility.
|
||||
Builds proper Unicode cmap from PDF ToUnicode data.
|
||||
"""
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
from fontTools.ttLib import TTFont, newTable
|
||||
from fontTools.cffLib import CFFFontSet
|
||||
from fontTools.ttLib.tables._c_m_a_p import cmap_format_4, cmap_format_12
|
||||
from fontTools.ttLib.tables._n_a_m_e import NameRecord
|
||||
from fontTools.ttLib.tables.O_S_2f_2 import Panose
|
||||
|
||||
def parse_unicode_mapping(mapping_path):
|
||||
"""
|
||||
Parse Unicode mapping (either JSON with CharCode→CID→GID→Unicode or raw ToUnicode CMap).
|
||||
|
||||
Returns:
|
||||
dict[int, int]: GID → Unicode codepoint
|
||||
"""
|
||||
try:
|
||||
with open(mapping_path, 'rb') as f:
|
||||
data = f.read().decode('utf-8', errors='ignore')
|
||||
|
||||
# Try parsing as JSON first (CID font with complete mapping)
|
||||
if data.strip().startswith('{'):
|
||||
import json
|
||||
try:
|
||||
mapping_data = json.loads(data)
|
||||
if mapping_data.get('isCID'):
|
||||
# Build GID → Unicode mapping from entries
|
||||
gid_to_unicode = {}
|
||||
for entry in mapping_data.get('entries', []):
|
||||
gid = entry['gid']
|
||||
unicode_val = entry['unicode']
|
||||
if unicode_val > 0:
|
||||
gid_to_unicode[gid] = unicode_val
|
||||
print(f"Parsed JSON mapping: {len(gid_to_unicode)} GID→Unicode entries", file=sys.stderr)
|
||||
return gid_to_unicode
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fall back to parsing raw ToUnicode CMap (non-CID fonts)
|
||||
# For non-CID fonts, CID/GID is the same as array index
|
||||
gid_to_unicode = {}
|
||||
|
||||
# Pattern for bfchar entries
|
||||
bfchar_pattern = r'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>'
|
||||
for match in re.finditer(bfchar_pattern, data):
|
||||
gid = int(match.group(1), 16) # For non-CID, char code == GID
|
||||
unicode_val = int(match.group(2), 16)
|
||||
if unicode_val > 0:
|
||||
gid_to_unicode[gid] = unicode_val
|
||||
|
||||
# Pattern for bfrange entries
|
||||
bfrange_pattern = r'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>'
|
||||
for match in re.finditer(bfrange_pattern, data):
|
||||
start_gid = int(match.group(1), 16)
|
||||
end_gid = int(match.group(2), 16)
|
||||
start_unicode = int(match.group(3), 16)
|
||||
for i, gid in enumerate(range(start_gid, end_gid + 1)):
|
||||
unicode_val = start_unicode + i
|
||||
if unicode_val > 0:
|
||||
gid_to_unicode[gid] = unicode_val
|
||||
|
||||
print(f"Parsed ToUnicode CMap: {len(gid_to_unicode)} mappings", file=sys.stderr)
|
||||
return gid_to_unicode
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to parse Unicode mapping: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
def wrap_cff_as_otf(input_path, output_path, tounicode_path=None):
|
||||
"""
|
||||
Wrap raw CFF data (from PDF font stream) as OpenType-CFF.
|
||||
|
||||
Args:
|
||||
input_path: Path to input CFF data file
|
||||
output_path: Path to output OTF font
|
||||
tounicode_path: Optional path to ToUnicode CMap file
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Read raw CFF data
|
||||
with open(input_path, 'rb') as f:
|
||||
cff_data = f.read()
|
||||
|
||||
# Parse raw CFF data
|
||||
cff_fontset = CFFFontSet()
|
||||
cff_fontset.decompile(BytesIO(cff_data), None)
|
||||
|
||||
# Get the first (and usually only) font in the CFF set
|
||||
if len(cff_fontset.fontNames) == 0:
|
||||
print("ERROR: No fonts found in CFF data", file=sys.stderr)
|
||||
return False
|
||||
|
||||
cff_font = cff_fontset[cff_fontset.fontNames[0]]
|
||||
|
||||
# Parse Unicode mapping (JSON or raw ToUnicode CMap) if provided
|
||||
gid_to_unicode = {}
|
||||
if tounicode_path:
|
||||
gid_to_unicode = parse_unicode_mapping(tounicode_path)
|
||||
|
||||
# Create a new OTF font
|
||||
otf = TTFont(sfntVersion='OTTO') # 'OTTO' = CFF-flavored OpenType
|
||||
|
||||
# Get glyph names
|
||||
if hasattr(cff_font, 'charset') and cff_font.charset is not None:
|
||||
glyph_order = ['.notdef'] + [name for name in cff_font.charset if name != '.notdef']
|
||||
else:
|
||||
# Fallback to CharStrings keys
|
||||
charstrings = cff_font.CharStrings
|
||||
glyph_order = ['.notdef'] + [name for name in charstrings.keys() if name != '.notdef']
|
||||
|
||||
otf.setGlyphOrder(glyph_order)
|
||||
|
||||
# === Add CFF table (the actual font outlines) ===
|
||||
cff_table = newTable('CFF ')
|
||||
cff_table.cff = cff_fontset
|
||||
otf['CFF '] = cff_table
|
||||
|
||||
# === Calculate metrics from CFF ===
|
||||
charstrings = cff_font.CharStrings
|
||||
|
||||
# Get defaults from CFF Private dict
|
||||
private_dict = getattr(cff_font, 'Private', None)
|
||||
default_width = getattr(private_dict, 'defaultWidthX', 500) if private_dict else 500
|
||||
|
||||
# Calculate bounding box, widths, and LSBs
|
||||
x_min = 0
|
||||
y_min = -200
|
||||
x_max = 1000
|
||||
y_max = 800
|
||||
max_advance = 0
|
||||
min_lsb = 0
|
||||
min_rsb = 0
|
||||
max_extent = 0
|
||||
|
||||
widths = {}
|
||||
lsbs = {}
|
||||
|
||||
for glyph_name in glyph_order:
|
||||
lsb = 0
|
||||
width = int(default_width)
|
||||
|
||||
if glyph_name in charstrings:
|
||||
try:
|
||||
cs = charstrings[glyph_name]
|
||||
|
||||
# Get width from charstring
|
||||
if hasattr(cs, 'width'):
|
||||
width = int(cs.width)
|
||||
|
||||
# Calculate bounds for LSB and bbox
|
||||
try:
|
||||
bounds = cs.calcBounds(None)
|
||||
if bounds:
|
||||
glyph_xmin = int(bounds[0])
|
||||
glyph_ymin = int(bounds[1])
|
||||
glyph_xmax = int(bounds[2])
|
||||
glyph_ymax = int(bounds[3])
|
||||
|
||||
lsb = glyph_xmin
|
||||
rsb = width - glyph_xmax
|
||||
extent = lsb + glyph_xmax
|
||||
|
||||
# Update global bounds
|
||||
x_min = min(x_min, glyph_xmin)
|
||||
y_min = min(y_min, glyph_ymin)
|
||||
x_max = max(x_max, glyph_xmax)
|
||||
y_max = max(y_max, glyph_ymax)
|
||||
|
||||
# Update hhea metrics
|
||||
min_lsb = min(min_lsb, lsb)
|
||||
min_rsb = min(min_rsb, rsb)
|
||||
max_extent = max(max_extent, extent)
|
||||
except:
|
||||
pass # Some glyphs may not have outlines
|
||||
|
||||
except Exception as e:
|
||||
pass # Use defaults
|
||||
|
||||
widths[glyph_name] = width
|
||||
lsbs[glyph_name] = lsb
|
||||
max_advance = max(max_advance, width)
|
||||
|
||||
if max_advance == 0:
|
||||
max_advance = 1000
|
||||
if max_extent == 0:
|
||||
max_extent = x_max
|
||||
|
||||
units_per_em = 1000 # Standard for Type1/CFF
|
||||
|
||||
# === Create head table ===
|
||||
head = newTable('head')
|
||||
head.tableVersion = 1.0
|
||||
head.fontRevision = 1.0
|
||||
head.checkSumAdjustment = 0
|
||||
head.magicNumber = 0x5F0F3CF5
|
||||
head.flags = 0x000B # Baseline at y=0, LSB at x=0, integer PPEM
|
||||
head.unitsPerEm = units_per_em
|
||||
head.created = 3600000000
|
||||
head.modified = 3600000000
|
||||
head.xMin = x_min
|
||||
head.yMin = y_min
|
||||
head.xMax = x_max
|
||||
head.yMax = y_max
|
||||
head.macStyle = 0
|
||||
head.fontDirectionHint = 2
|
||||
head.indexToLocFormat = 0
|
||||
head.glyphDataFormat = 0
|
||||
head.lowestRecPPEM = 8
|
||||
otf['head'] = head
|
||||
|
||||
# === Create hhea table with correct metrics ===
|
||||
hhea = newTable('hhea')
|
||||
hhea.tableVersion = 0x00010000
|
||||
hhea.ascent = max(y_max, 800)
|
||||
hhea.descent = min(y_min, -200)
|
||||
hhea.lineGap = 0
|
||||
hhea.advanceWidthMax = max_advance
|
||||
hhea.minLeftSideBearing = min_lsb
|
||||
hhea.minRightSideBearing = min_rsb
|
||||
hhea.xMaxExtent = max_extent
|
||||
hhea.caretSlopeRise = 1
|
||||
hhea.caretSlopeRun = 0
|
||||
hhea.caretOffset = 0
|
||||
hhea.reserved0 = 0
|
||||
hhea.reserved1 = 0
|
||||
hhea.reserved2 = 0
|
||||
hhea.reserved3 = 0
|
||||
hhea.metricDataFormat = 0
|
||||
hhea.numberOfHMetrics = len(glyph_order)
|
||||
otf['hhea'] = hhea
|
||||
|
||||
# === Create hmtx table with correct LSBs ===
|
||||
hmtx = newTable('hmtx')
|
||||
hmtx.metrics = {}
|
||||
for glyph_name in glyph_order:
|
||||
hmtx.metrics[glyph_name] = (widths.get(glyph_name, default_width), lsbs.get(glyph_name, 0))
|
||||
otf['hmtx'] = hmtx
|
||||
|
||||
# === Create maxp table (simpler for CFF) ===
|
||||
maxp = newTable('maxp')
|
||||
maxp.tableVersion = 0x00005000 # CFF version (0.5)
|
||||
maxp.numGlyphs = len(glyph_order)
|
||||
otf['maxp'] = maxp
|
||||
|
||||
# === Build Unicode cmap from GID→Unicode mapping ===
|
||||
unicode_to_glyph = {}
|
||||
|
||||
if gid_to_unicode:
|
||||
# Debug: Show first few glyph names to understand naming convention
|
||||
sample_glyphs = glyph_order[:min(10, len(glyph_order))]
|
||||
print(f"Sample glyph names: {sample_glyphs}", file=sys.stderr)
|
||||
|
||||
# Debug: Show which GIDs we have mappings for
|
||||
sample_gids = sorted(gid_to_unicode.keys())[:10]
|
||||
print(f"Sample GIDs from mapping: {sample_gids}", file=sys.stderr)
|
||||
|
||||
# For CID fonts: glyph names are "cid00123" (5-digit zero-padded)
|
||||
# For non-CID fonts: glyph names vary but GID == array index
|
||||
is_cid_font = any(gn.startswith('cid') for gn in glyph_order[1:6]) # Check first few non-.notdef glyphs
|
||||
|
||||
for gid, unicode_val in gid_to_unicode.items():
|
||||
if unicode_val > 0:
|
||||
if is_cid_font:
|
||||
# Build glyph name as cidNNNNN (5 digits, zero-padded)
|
||||
glyph_name = f"cid{gid:05d}"
|
||||
# Verify this glyph exists in glyph_order
|
||||
if glyph_name in glyph_order:
|
||||
unicode_to_glyph[unicode_val] = glyph_name
|
||||
else:
|
||||
# Try without padding (some fonts use "cid123" not "cid00123")
|
||||
glyph_name_alt = f"cid{gid}"
|
||||
if glyph_name_alt in glyph_order:
|
||||
unicode_to_glyph[unicode_val] = glyph_name_alt
|
||||
else:
|
||||
# Non-CID font: GID is array index
|
||||
if 0 <= gid < len(glyph_order):
|
||||
glyph_name = glyph_order[gid]
|
||||
unicode_to_glyph[unicode_val] = glyph_name
|
||||
|
||||
print(f"Mapped {len(unicode_to_glyph)} Unicode codepoints (isCID={is_cid_font if gid_to_unicode else 'unknown'})", file=sys.stderr)
|
||||
|
||||
# Also try to map from glyph names (uni0041 → U+0041)
|
||||
for glyph_name in glyph_order:
|
||||
if glyph_name.startswith('uni') and len(glyph_name) == 7:
|
||||
try:
|
||||
unicode_val = int(glyph_name[3:], 16)
|
||||
if unicode_val not in unicode_to_glyph:
|
||||
unicode_to_glyph[unicode_val] = glyph_name
|
||||
except:
|
||||
pass
|
||||
elif glyph_name.startswith('u') and len(glyph_name) >= 5:
|
||||
try:
|
||||
unicode_val = int(glyph_name[1:], 16)
|
||||
if unicode_val not in unicode_to_glyph:
|
||||
unicode_to_glyph[unicode_val] = glyph_name
|
||||
except:
|
||||
pass
|
||||
|
||||
# === Create cmap table ===
|
||||
cmap = newTable('cmap')
|
||||
cmap.tableVersion = 0
|
||||
cmap_tables = []
|
||||
|
||||
# Windows Unicode BMP (format 4) - required
|
||||
cmap4_win = cmap_format_4(4)
|
||||
cmap4_win.platformID = 3 # Windows
|
||||
cmap4_win.platEncID = 1 # Unicode BMP
|
||||
cmap4_win.language = 0
|
||||
cmap4_win.cmap = {cp: gn for cp, gn in unicode_to_glyph.items() if cp <= 0xFFFF}
|
||||
cmap_tables.append(cmap4_win)
|
||||
|
||||
# Windows Unicode UCS-4 (format 12) - for >BMP
|
||||
if any(cp > 0xFFFF for cp in unicode_to_glyph):
|
||||
cmap12_win = cmap_format_12(12)
|
||||
cmap12_win.platformID = 3 # Windows
|
||||
cmap12_win.platEncID = 10 # Unicode UCS-4
|
||||
cmap12_win.language = 0
|
||||
cmap12_win.cmap = dict(unicode_to_glyph)
|
||||
cmap_tables.append(cmap12_win)
|
||||
|
||||
# Mac Unicode (format 4) - for compatibility
|
||||
cmap4_mac = cmap_format_4(4)
|
||||
cmap4_mac.platformID = 1 # Mac
|
||||
cmap4_mac.platEncID = 0 # Roman
|
||||
cmap4_mac.language = 0
|
||||
cmap4_mac.cmap = {cp: gn for cp, gn in unicode_to_glyph.items() if cp <= 0xFFFF}
|
||||
cmap_tables.append(cmap4_mac)
|
||||
|
||||
cmap.tables = [t for t in cmap_tables if t.cmap] or [cmap4_win] # Ensure at least one
|
||||
otf['cmap'] = cmap
|
||||
|
||||
print(f"Built cmap with {len(unicode_to_glyph)} Unicode mappings", file=sys.stderr)
|
||||
|
||||
# === Create OS/2 table with correct metrics ===
|
||||
os2 = newTable('OS/2')
|
||||
os2.version = 4
|
||||
os2.xAvgCharWidth = int(sum(widths.values()) / len(widths)) if widths else 500
|
||||
os2.usWeightClass = 400 # Normal
|
||||
os2.usWidthClass = 5 # Medium
|
||||
os2.fsType = 0 # Installable embedding
|
||||
os2.ySubscriptXSize = 650
|
||||
os2.ySubscriptYSize = 600
|
||||
os2.ySubscriptXOffset = 0
|
||||
os2.ySubscriptYOffset = 75
|
||||
os2.ySuperscriptXSize = 650
|
||||
os2.ySuperscriptYSize = 600
|
||||
os2.ySuperscriptXOffset = 0
|
||||
os2.ySuperscriptYOffset = 350
|
||||
os2.yStrikeoutSize = 50
|
||||
os2.yStrikeoutPosition = 300
|
||||
os2.sFamilyClass = 0
|
||||
|
||||
# PANOSE - use proper object structure
|
||||
os2.panose = Panose()
|
||||
os2.panose.bFamilyType = 0
|
||||
os2.panose.bSerifStyle = 0
|
||||
os2.panose.bWeight = 0
|
||||
os2.panose.bProportion = 0
|
||||
os2.panose.bContrast = 0
|
||||
os2.panose.bStrokeVariation = 0
|
||||
os2.panose.bArmStyle = 0
|
||||
os2.panose.bLetterForm = 0
|
||||
os2.panose.bMidline = 0
|
||||
os2.panose.bXHeight = 0
|
||||
|
||||
os2.ulUnicodeRange1 = 0
|
||||
os2.ulUnicodeRange2 = 0
|
||||
os2.ulUnicodeRange3 = 0
|
||||
os2.ulUnicodeRange4 = 0
|
||||
os2.achVendID = 'SPDF'
|
||||
os2.fsSelection = 0x0040 # REGULAR bit
|
||||
|
||||
# Set character index range from actual cmap
|
||||
if unicode_to_glyph:
|
||||
codepoints = sorted(unicode_to_glyph.keys())
|
||||
os2.usFirstCharIndex = codepoints[0]
|
||||
os2.usLastCharIndex = codepoints[-1]
|
||||
else:
|
||||
os2.usFirstCharIndex = 0x20 # space
|
||||
os2.usLastCharIndex = 0x7E # tilde
|
||||
|
||||
# Typo metrics match hhea
|
||||
os2.sTypoAscender = hhea.ascent
|
||||
os2.sTypoDescender = hhea.descent
|
||||
os2.sTypoLineGap = hhea.lineGap
|
||||
|
||||
# Windows metrics (positive values, cover bbox)
|
||||
os2.usWinAscent = max(0, y_max)
|
||||
os2.usWinDescent = max(0, -y_min)
|
||||
|
||||
os2.ulCodePageRange1 = 0x00000001 # Latin 1
|
||||
os2.ulCodePageRange2 = 0
|
||||
os2.sxHeight = 500
|
||||
os2.sCapHeight = 700
|
||||
os2.usDefaultChar = 0
|
||||
os2.usBreakChar = 32
|
||||
os2.usMaxContext = 0
|
||||
otf['OS/2'] = os2
|
||||
|
||||
# === Create name table with Windows and Mac records ===
|
||||
name = newTable('name')
|
||||
name.names = []
|
||||
|
||||
# Get font name from CFF if available
|
||||
font_name = cff_fontset.fontNames[0] if cff_fontset.fontNames else "Converted"
|
||||
|
||||
name_strings = {
|
||||
1: font_name, # Font Family
|
||||
2: "Regular", # Subfamily
|
||||
3: f"Stirling-PDF: {font_name}", # Unique ID
|
||||
4: font_name, # Full Name
|
||||
5: "Version 1.0", # Version
|
||||
6: font_name.replace(' ', '-'), # PostScript Name
|
||||
}
|
||||
|
||||
# Add both Windows and Mac name records
|
||||
for name_id, value in name_strings.items():
|
||||
# Windows (platform 3, encoding 1, language 0x0409 = en-US)
|
||||
rec_win = NameRecord()
|
||||
rec_win.nameID = name_id
|
||||
rec_win.platformID = 3
|
||||
rec_win.platEncID = 1
|
||||
rec_win.langID = 0x0409
|
||||
rec_win.string = value
|
||||
name.names.append(rec_win)
|
||||
|
||||
# Mac (platform 1, encoding 0, language 0)
|
||||
rec_mac = NameRecord()
|
||||
rec_mac.nameID = name_id
|
||||
rec_mac.platformID = 1
|
||||
rec_mac.platEncID = 0
|
||||
rec_mac.langID = 0
|
||||
rec_mac.string = value
|
||||
name.names.append(rec_mac)
|
||||
|
||||
otf['name'] = name
|
||||
|
||||
# === Create post table (format 3.0 for smaller web fonts) ===
|
||||
post = newTable('post')
|
||||
post.formatType = 3.0 # No glyph names (smaller, web-optimized)
|
||||
post.italicAngle = 0
|
||||
post.underlinePosition = -100
|
||||
post.underlineThickness = 50
|
||||
post.isFixedPitch = 0
|
||||
post.minMemType42 = 0
|
||||
post.maxMemType42 = 0
|
||||
post.minMemType1 = 0
|
||||
post.maxMemType1 = 0
|
||||
otf['post'] = post
|
||||
|
||||
# Save the OTF font
|
||||
otf.save(output_path)
|
||||
otf.close()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Conversion failed: {str(e)}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return False
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: convert_cff_to_ttf.py <input.cff> <output.otf> [tounicode.cmap]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
input_path = Path(sys.argv[1])
|
||||
output_path = Path(sys.argv[2])
|
||||
tounicode_path = Path(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"ERROR: Input file not found: {input_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if tounicode_path and not tounicode_path.exists():
|
||||
print(f"Warning: ToUnicode file not found: {tounicode_path}", file=sys.stderr)
|
||||
tounicode_path = None
|
||||
|
||||
success = wrap_cff_as_otf(str(input_path), str(output_path), str(tounicode_path) if tounicode_path else None)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Download large batches of PDF URLs into a local directory so they can be fed to
|
||||
scripts/harvest_type3_fonts.py (or any other processing pipeline).
|
||||
|
||||
Usage examples:
|
||||
|
||||
# Download every URL listed in pdf_urls.txt into tmp/type3-pdfs
|
||||
python scripts/download_pdf_samples.py \
|
||||
--urls-file pdf_urls.txt \
|
||||
--output-dir tmp/type3-pdfs
|
||||
|
||||
# Mix inline URLs with a file and use 16 concurrent downloads
|
||||
python scripts/download_pdf_samples.py \
|
||||
--urls https://example.com/a.pdf https://example.com/b.pdf \
|
||||
--urls-file more_urls.txt \
|
||||
--output-dir tmp/type3-pdfs \
|
||||
--workers 16
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional, Set, Tuple
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Bulk download PDF URLs.")
|
||||
parser.add_argument(
|
||||
"--urls",
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="Inline list of PDF URLs (can be combined with --urls-file).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--urls-file",
|
||||
action="append",
|
||||
help="Text file containing one URL per line (can be repeated).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default="tmp/harvest-pdfs",
|
||||
help="Directory to store downloaded PDFs (default: %(default)s).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=min(8, (os.cpu_count() or 4) * 2),
|
||||
help="Number of concurrent downloads (default: %(default)s).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=120,
|
||||
help="Per-request timeout in seconds (default: %(default)s).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Overwrite existing files (default: skip already downloaded PDFs).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_urls(args: argparse.Namespace) -> List[str]:
|
||||
urls: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
|
||||
def add(url: str) -> None:
|
||||
clean = url.strip()
|
||||
if not clean or clean.startswith("#"):
|
||||
return
|
||||
if clean not in seen:
|
||||
seen.add(clean)
|
||||
urls.append(clean)
|
||||
|
||||
for url in args.urls:
|
||||
add(url)
|
||||
if args.urls_file:
|
||||
for file in args.urls_file:
|
||||
path = Path(file)
|
||||
if not path.exists():
|
||||
print(f"[WARN] URL file not found: {file}", file=sys.stderr)
|
||||
continue
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
add(line)
|
||||
if not urls:
|
||||
raise SystemExit("No URLs supplied. Use --urls and/or --urls-file.")
|
||||
return urls
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_") or "download"
|
||||
|
||||
|
||||
def build_filename(url: str, output_dir: Path) -> Path:
|
||||
parsed = urlparse(url)
|
||||
candidate = Path(unquote(parsed.path)).name
|
||||
if not candidate:
|
||||
candidate = "download.pdf"
|
||||
candidate = sanitize_filename(candidate)
|
||||
if not candidate.lower().endswith(".pdf"):
|
||||
candidate += ".pdf"
|
||||
target = output_dir / candidate
|
||||
if not target.exists():
|
||||
return target
|
||||
stem = target.stem
|
||||
suffix = target.suffix
|
||||
digest = hashlib.sha1(url.encode("utf-8")).hexdigest()[:8]
|
||||
return output_dir / f"{stem}-{digest}{suffix}"
|
||||
|
||||
|
||||
def download_pdf(
|
||||
url: str,
|
||||
output_dir: Path,
|
||||
timeout: int,
|
||||
overwrite: bool,
|
||||
) -> Tuple[str, Optional[Path], Optional[str]]:
|
||||
try:
|
||||
dest = build_filename(url, output_dir)
|
||||
if dest.exists() and not overwrite:
|
||||
return url, dest, "exists"
|
||||
|
||||
response = requests.get(url, stream=True, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("Content-Type", "").lower()
|
||||
if "pdf" not in content_type and not url.lower().endswith(".pdf"):
|
||||
# Peek into the first bytes to be safe
|
||||
peek = response.raw.read(5, decode_content=True)
|
||||
if not peek.startswith(b"%PDF"):
|
||||
return url, None, f"Skipping non-PDF content-type ({content_type or 'unknown'})"
|
||||
content = peek + response.content[len(peek):]
|
||||
else:
|
||||
content = response.content
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(content)
|
||||
return url, dest, None
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return url, None, str(exc)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
urls = load_urls(args)
|
||||
output_dir = Path(args.output_dir).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Downloading {len(urls)} PDFs to {output_dir} using {args.workers} workers...")
|
||||
|
||||
successes = 0
|
||||
skipped = 0
|
||||
failures: List[Tuple[str, str]] = []
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||
future_to_url = {
|
||||
executor.submit(
|
||||
download_pdf, url, output_dir, args.timeout, args.overwrite
|
||||
): url
|
||||
for url in urls
|
||||
}
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
result_url, path, error = future.result()
|
||||
if error == "exists":
|
||||
skipped += 1
|
||||
print(f"[SKIP] {url} (already downloaded)")
|
||||
elif error:
|
||||
failures.append((result_url, error))
|
||||
print(f"[FAIL] {url} -> {error}", file=sys.stderr)
|
||||
else:
|
||||
successes += 1
|
||||
print(f"[OK] {url} -> {path}")
|
||||
|
||||
print()
|
||||
print(f"Completed. Success: {successes}, Skipped: {skipped}, Failures: {len(failures)}")
|
||||
if failures:
|
||||
print("Failures:")
|
||||
for url, error in failures:
|
||||
print(f" {url} -> {error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bulk-harvest Type3 font signatures from a folder full of PDFs.
|
||||
|
||||
The script iterates over every PDF (recursively) inside the supplied --input
|
||||
paths, invokes the existing Gradle Type3SignatureTool for each document, and
|
||||
collects the unique Type3 font signatures that were discovered. Signature JSON
|
||||
files are stored under --signatures-dir; previously captured files are reused
|
||||
so you can keep dropping new PDFs into the input directory and re-run the
|
||||
harvester at any time.
|
||||
|
||||
Example:
|
||||
python scripts/harvest_type3_fonts.py \
|
||||
--input incoming-type3-pdfs \
|
||||
--signatures docs/type3/signatures \
|
||||
--report docs/type3/harvest_report.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Bulk collect Type3 font signatures from PDFs.")
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
nargs="+",
|
||||
required=True,
|
||||
help="One or more PDF files or directories containing PDFs (searched recursively).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--signatures-dir",
|
||||
default="docs/type3/signatures",
|
||||
help="Destination directory for per-PDF signature JSON files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report",
|
||||
default="docs/type3/harvest_report.json",
|
||||
help="Summary JSON that lists every unique signature discovered so far.",
|
||||
)
|
||||
default_gradle = "gradlew.bat" if os.name == "nt" else "./gradlew"
|
||||
parser.add_argument(
|
||||
"--gradle-cmd",
|
||||
default=default_gradle,
|
||||
help=f"Path to the Gradle wrapper used to invoke the Type3SignatureTool (default: {default_gradle}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Re-run the signature tool even if the output JSON already exists.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pretty",
|
||||
action="store_true",
|
||||
help="Ask the Java tool to emit pretty-printed JSON (handy for diffs).",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def discover_pdfs(paths: Sequence[str]) -> List[Path]:
|
||||
pdfs: List[Path] = []
|
||||
for raw in paths:
|
||||
path = Path(raw).resolve()
|
||||
if path.is_file():
|
||||
if path.suffix.lower() == ".pdf":
|
||||
pdfs.append(path)
|
||||
elif path.is_dir():
|
||||
pdfs.extend(sorted(path.rglob("*.pdf")))
|
||||
unique = sorted(dict.fromkeys(pdfs))
|
||||
if not unique:
|
||||
raise SystemExit("No PDF files found under the supplied --input paths.")
|
||||
return unique
|
||||
|
||||
|
||||
def sanitize_part(part: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", part)
|
||||
return cleaned or "_"
|
||||
|
||||
|
||||
def derive_signature_path(pdf: Path, signatures_dir: Path) -> Path:
|
||||
"""
|
||||
Mirror the PDF path under the signatures directory.
|
||||
If the PDF lives outside the repo, fall back to a hashed filename.
|
||||
"""
|
||||
try:
|
||||
rel = pdf.relative_to(REPO_ROOT)
|
||||
except ValueError:
|
||||
digest = hashlib.sha1(str(pdf).encode("utf-8")).hexdigest()[:10]
|
||||
rel = Path("__external__") / f"{sanitize_part(pdf.stem)}-{digest}.pdf"
|
||||
|
||||
sanitized_parts = [sanitize_part(part) for part in rel.parts]
|
||||
signature_rel = Path(*sanitized_parts).with_suffix(".json")
|
||||
return signatures_dir / signature_rel
|
||||
|
||||
|
||||
def load_signature_file(path: Path) -> dict:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def collect_known_signatures(signatures_dir: Path) -> Dict[str, dict]:
|
||||
known: Dict[str, dict] = {}
|
||||
if not signatures_dir.exists():
|
||||
return known
|
||||
for json_file in signatures_dir.rglob("*.json"):
|
||||
try:
|
||||
payload = load_signature_file(json_file)
|
||||
except Exception:
|
||||
continue
|
||||
pdf = payload.get("pdf")
|
||||
for font in payload.get("fonts", []):
|
||||
signature = font.get("signature")
|
||||
if not signature or signature in known:
|
||||
continue
|
||||
known[signature] = {
|
||||
"signature": signature,
|
||||
"alias": font.get("alias"),
|
||||
"baseName": font.get("baseName"),
|
||||
"glyphCount": font.get("glyphCount"),
|
||||
"glyphCoverage": font.get("glyphCoverage"),
|
||||
"samplePdf": pdf,
|
||||
"signatureJson": str(json_file),
|
||||
}
|
||||
return known
|
||||
|
||||
|
||||
def run_signature_tool(
|
||||
gradle_cmd: str, pdf: Path, output_path: Path, pretty: bool, cwd: Path
|
||||
) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
args = f"--pdf {shlex.quote(str(pdf))} --output {shlex.quote(str(output_path))}"
|
||||
if pretty:
|
||||
args += " --pretty"
|
||||
# Use shell invocation so the quoted --args string is parsed correctly by Gradle.
|
||||
cmd = f"{gradle_cmd} -q :proprietary:type3SignatureTool --args=\"{args}\""
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Gradle Type3SignatureTool failed for {pdf}:\n{completed.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def extract_fonts_from_payload(payload: dict) -> List[dict]:
|
||||
pdf = payload.get("pdf")
|
||||
fonts = []
|
||||
for font in payload.get("fonts", []):
|
||||
signature = font.get("signature")
|
||||
if not signature:
|
||||
continue
|
||||
fonts.append(
|
||||
{
|
||||
"signature": signature,
|
||||
"alias": font.get("alias"),
|
||||
"baseName": font.get("baseName"),
|
||||
"glyphCount": font.get("glyphCount"),
|
||||
"glyphCoverage": font.get("glyphCoverage"),
|
||||
"samplePdf": pdf,
|
||||
}
|
||||
)
|
||||
return fonts
|
||||
|
||||
|
||||
def write_report(report_path: Path, fonts_by_signature: Dict[str, dict]) -> None:
|
||||
ordered = sorted(fonts_by_signature.values(), key=lambda entry: entry["signature"])
|
||||
report = {
|
||||
"generatedAt": dt.datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||
"totalSignatures": len(ordered),
|
||||
"fonts": ordered,
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with report_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(report, handle, indent=2)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
signatures_dir = Path(args.signatures_dir).resolve()
|
||||
report_path = Path(args.report).resolve()
|
||||
pdfs = discover_pdfs(args.input)
|
||||
|
||||
known = collect_known_signatures(signatures_dir)
|
||||
newly_added: List[Tuple[str, str]] = []
|
||||
|
||||
for pdf in pdfs:
|
||||
signature_path = derive_signature_path(pdf, signatures_dir)
|
||||
if signature_path.exists() and not args.force:
|
||||
try:
|
||||
payload = load_signature_file(signature_path)
|
||||
except Exception as exc:
|
||||
print(f"[WARN] Failed to parse cached signature {signature_path}: {exc}")
|
||||
payload = None
|
||||
else:
|
||||
try:
|
||||
run_signature_tool(args.gradle_cmd, pdf, signature_path, args.pretty, REPO_ROOT)
|
||||
except Exception as exc:
|
||||
print(f"[ERROR] Harvest failed for {pdf}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
payload = load_signature_file(signature_path)
|
||||
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
for font in extract_fonts_from_payload(payload):
|
||||
signature = font["signature"]
|
||||
if signature in known:
|
||||
continue
|
||||
font["signatureJson"] = str(signature_path)
|
||||
known[signature] = font
|
||||
newly_added.append((signature, pdf.name))
|
||||
|
||||
write_report(report_path, known)
|
||||
|
||||
print(
|
||||
f"Processed {len(pdfs)} PDFs. "
|
||||
f"Captured {len(newly_added)} new Type3 font signatures "
|
||||
f"(total unique signatures: {len(known)})."
|
||||
)
|
||||
if newly_added:
|
||||
print("New signatures:")
|
||||
for signature, sample in newly_added:
|
||||
print(f" {signature} ({sample})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a Type3 font catalogue from sample PDFs."""
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(cmd, cwd=None):
|
||||
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Command {' '.join(cmd)} failed: {result.stderr}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def parse_pdffonts(output):
|
||||
lines = output.splitlines()
|
||||
entries = []
|
||||
for line in lines[2:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split()
|
||||
if "Type" not in parts:
|
||||
continue
|
||||
idx = parts.index("Type")
|
||||
type_value = parts[idx + 1] if idx + 1 < len(parts) else ""
|
||||
if not type_value.startswith("3"):
|
||||
continue
|
||||
font_name = parts[0]
|
||||
encoding = parts[-2] if len(parts) >= 2 else ""
|
||||
entries.append((font_name, encoding))
|
||||
return entries
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Index Type3 fonts from sample PDFs")
|
||||
parser.add_argument(
|
||||
"--samples",
|
||||
default="app/core/src/main/resources/type3/samples",
|
||||
help="Directory containing sample PDFs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="app/core/src/main/resources/type3/catalogue.json",
|
||||
help="Output JSON file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
samples_dir = Path(args.samples)
|
||||
out_path = Path(args.output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
catalogue = []
|
||||
for pdf in sorted(samples_dir.glob("*.pdf")):
|
||||
try:
|
||||
output = run(["pdffonts", str(pdf)])
|
||||
except Exception as exc:
|
||||
print(f"Skipping {pdf.name}: {exc}")
|
||||
continue
|
||||
for font_name, encoding in parse_pdffonts(output):
|
||||
catalogue.append(
|
||||
{
|
||||
"source": pdf.name,
|
||||
"fontName": font_name,
|
||||
"encoding": encoding,
|
||||
}
|
||||
)
|
||||
|
||||
with out_path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(catalogue, handle, indent=2)
|
||||
print(f"Wrote {len(catalogue)} entries to {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Summarize captured Type3 signature dumps as a Markdown inventory.
|
||||
|
||||
Usage:
|
||||
scripts/summarize_type3_signatures.py \
|
||||
--input docs/type3/signatures \
|
||||
--output docs/type3/signature_inventory.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Summarize Type3 signature JSON dumps.")
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
default="docs/type3/signatures",
|
||||
help="Directory containing signature JSON files (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="docs/type3/signature_inventory.md",
|
||||
help="Markdown file to write (default: %(default)s)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_signatures(directory: Path) -> Dict[str, List[dict]]:
|
||||
inventory: Dict[str, List[dict]] = defaultdict(list)
|
||||
for path in sorted(directory.glob("*.json")):
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
source_pdf = payload.get("pdf") or path.name
|
||||
for font in payload.get("fonts", []):
|
||||
alias = (font.get("alias") or font.get("baseName") or "unknown").lower()
|
||||
entry = {
|
||||
"source": source_pdf,
|
||||
"file": path.name,
|
||||
"alias": alias,
|
||||
"baseName": font.get("baseName"),
|
||||
"signature": font.get("signature"),
|
||||
"glyphCount": font.get("glyphCount"),
|
||||
"glyphCoverage": font.get("glyphCoverage"),
|
||||
}
|
||||
inventory[alias].append(entry)
|
||||
return inventory
|
||||
|
||||
|
||||
def write_markdown(inventory: Dict[str, List[dict]], output: Path, input_dir: Path) -> None:
|
||||
lines: List[str] = []
|
||||
lines.append("# Type3 Signature Inventory")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"_Generated from `{input_dir}`. "
|
||||
"Run `scripts/summarize_type3_signatures.py` after capturing new samples._"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
for alias in sorted(inventory.keys()):
|
||||
entries = inventory[alias]
|
||||
lines.append(f"## Alias: `{alias}`")
|
||||
lines.append("")
|
||||
lines.append("| Signature | Samples | Glyph Count | Coverage (first 10) |")
|
||||
lines.append("| --- | --- | --- | --- |")
|
||||
for entry in entries:
|
||||
signature = entry.get("signature") or "—"
|
||||
sample = Path(entry["source"]).name
|
||||
glyph_count = entry.get("glyphCount") if entry.get("glyphCount") is not None else "—"
|
||||
coverage = entry.get("glyphCoverage") or []
|
||||
preview = ", ".join(str(code) for code in coverage[:10])
|
||||
lines.append(f"| `{signature}` | `{sample}` | {glyph_count} | {preview} |")
|
||||
lines.append("")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
input_dir = Path(args.input)
|
||||
if not input_dir.exists():
|
||||
raise SystemExit(f"Input directory not found: {input_dir}")
|
||||
inventory = load_signatures(input_dir)
|
||||
output_path = Path(args.output)
|
||||
write_markdown(inventory, output_path, input_dir)
|
||||
print(f"Wrote inventory for {len(inventory)} aliases to {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convert Stirling PDF Type3 glyph JSON into synthesised fonts using fontTools.
|
||||
|
||||
The input JSON is expected to contain:
|
||||
- fontId, pageNumber (optional metadata)
|
||||
- fontMatrix: 3x3 matrix describing the Type3 glyph transform
|
||||
- glyphs: array of glyph records with keys:
|
||||
name, code, advanceWidth, bbox, unicode, outline (list of commands)
|
||||
|
||||
The script produces an OpenType CFF font and, when requested, a companion
|
||||
TrueType font for web-preview usage. Only the fontTools package is required,
|
||||
avoiding heavyweight build dependencies such as fontmake/ufoLib2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.misc.fixedTools import otRound
|
||||
from fontTools.pens.cu2quPen import Cu2QuPen
|
||||
from fontTools.pens.t2CharStringPen import T2CharStringPen
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
|
||||
|
||||
Command = Dict[str, object]
|
||||
Matrix = Tuple[float, float, float, float, float, float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlyphSource:
|
||||
name: str
|
||||
width: float
|
||||
unicode: Optional[int]
|
||||
char_code: Optional[int]
|
||||
outline: Sequence[Command]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlyphBuildResult:
|
||||
name: str
|
||||
width: int
|
||||
charstring: object
|
||||
ttf_glyph: Optional[object]
|
||||
unicode: Optional[int]
|
||||
char_code: Optional[int]
|
||||
bounds: Optional[Tuple[float, float, float, float]]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Synthesize fonts from Type3 glyph JSON.")
|
||||
parser.add_argument("--input", required=True, help="Path to glyph JSON emitted by the backend")
|
||||
parser.add_argument("--otf-output", required=True, help="Destination path for the CFF/OTF font")
|
||||
parser.add_argument("--ttf-output", help="Optional destination path for a TrueType font")
|
||||
parser.add_argument("--family-name", default="Type3 Synth", help="Family name for the output")
|
||||
parser.add_argument("--style-name", default="Regular", help="Style name for the output")
|
||||
parser.add_argument("--units-per-em", type=int, default=1000, help="Units per EM value")
|
||||
parser.add_argument("--cu2qu-error", type=float, default=1.0, help="Max error for cubic→quadratic conversion")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_json(path: Path) -> Dict[str, object]:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except Exception as exc: # pragma: no cover - fatal configuration error
|
||||
print(f"ERROR: Failed to load glyph JSON '{path}': {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def parse_font_matrix(rows: Optional[Iterable[Iterable[float]]]) -> Matrix:
|
||||
"""
|
||||
Retrieve the raw 2×3 FontMatrix entries for diagnostics. Type3 glyph
|
||||
outlines in our extractor are emitted in their native coordinate system, so
|
||||
the returned matrix is currently informational only.
|
||||
"""
|
||||
if not rows:
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
values: List[List[float]] = []
|
||||
for row in rows:
|
||||
try:
|
||||
values.append([float(col) for col in row])
|
||||
except (TypeError, ValueError):
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
if len(values) < 3 or len(values[0]) < 2 or len(values[1]) < 2:
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
return (
|
||||
float(values[0][0]),
|
||||
float(values[0][1]),
|
||||
float(values[1][0]),
|
||||
float(values[1][1]),
|
||||
float(values[2][0]),
|
||||
float(values[2][1]),
|
||||
)
|
||||
|
||||
|
||||
def resolve_width(raw_width: float, default: int) -> int:
|
||||
try:
|
||||
value = float(raw_width)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return default
|
||||
width = otRound(value)
|
||||
return width if width > 0 else default
|
||||
|
||||
|
||||
def quadratic_to_cubic(
|
||||
current: Tuple[float, float],
|
||||
ctrl: Tuple[float, float],
|
||||
end: Tuple[float, float],
|
||||
) -> Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]:
|
||||
"""
|
||||
Convert a quadratic Bézier segment to cubic control points.
|
||||
"""
|
||||
c1 = (
|
||||
current[0] + (2.0 / 3.0) * (ctrl[0] - current[0]),
|
||||
current[1] + (2.0 / 3.0) * (ctrl[1] - current[1]),
|
||||
)
|
||||
c2 = (
|
||||
end[0] + (2.0 / 3.0) * (ctrl[0] - end[0]),
|
||||
end[1] + (2.0 / 3.0) * (ctrl[1] - end[1]),
|
||||
)
|
||||
return c1, c2, end
|
||||
|
||||
|
||||
def iterate_glyphs(data: Dict[str, object]) -> List[GlyphSource]:
|
||||
glyph_records = data.get("glyphs") or []
|
||||
sources: List[GlyphSource] = []
|
||||
for index, record in enumerate(glyph_records, start=1):
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
name = record.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
name = f"g{index}"
|
||||
width = record.get("advanceWidth")
|
||||
if not isinstance(width, (int, float)) or math.isnan(width):
|
||||
width = 1000.0
|
||||
unicode_value = record.get("unicode")
|
||||
if not isinstance(unicode_value, int) or unicode_value <= 0:
|
||||
unicode_value = None
|
||||
char_code_value = record.get("charCode")
|
||||
if not isinstance(char_code_value, int):
|
||||
char_code_value = record.get("code")
|
||||
if not isinstance(char_code_value, int):
|
||||
char_code_value = record.get("charCodeRaw")
|
||||
if not isinstance(char_code_value, int) or not (0 <= char_code_value <= 0x10FFFF):
|
||||
char_code_value = None
|
||||
outline = record.get("outline")
|
||||
if not isinstance(outline, list):
|
||||
outline = []
|
||||
sources.append(
|
||||
GlyphSource(
|
||||
name=name,
|
||||
width=float(width),
|
||||
unicode=unicode_value,
|
||||
char_code=char_code_value,
|
||||
outline=outline))
|
||||
return sources
|
||||
|
||||
|
||||
def build_cff_charstring(
|
||||
glyph: GlyphSource,
|
||||
width: int,
|
||||
) -> Tuple[object, Optional[Tuple[float, float, float, float]]]:
|
||||
pen = T2CharStringPen(width=width, glyphSet=None)
|
||||
bounds = [math.inf, math.inf, -math.inf, -math.inf]
|
||||
|
||||
def update_bounds(point: Tuple[float, float]) -> None:
|
||||
x, y = point
|
||||
bounds[0] = min(bounds[0], x)
|
||||
bounds[1] = min(bounds[1], y)
|
||||
bounds[2] = max(bounds[2], x)
|
||||
bounds[3] = max(bounds[3], y)
|
||||
|
||||
current: Optional[Tuple[float, float]] = None
|
||||
start_point: Optional[Tuple[float, float]] = None
|
||||
open_path = False
|
||||
|
||||
for command in glyph.outline:
|
||||
if not isinstance(command, dict):
|
||||
continue
|
||||
op = command.get("cmd")
|
||||
if op == "M":
|
||||
if open_path:
|
||||
pen.endPath()
|
||||
open_path = False
|
||||
point = (float(command.get("x", 0.0)), float(command.get("y", 0.0)))
|
||||
pen.moveTo(point)
|
||||
update_bounds(point)
|
||||
current = point
|
||||
start_point = point
|
||||
open_path = True
|
||||
elif op == "L" and current is not None:
|
||||
point = (float(command.get("x", current[0])), float(command.get("y", current[1])))
|
||||
pen.lineTo(point)
|
||||
update_bounds(point)
|
||||
current = point
|
||||
elif op == "C" and current is not None:
|
||||
ctrl1 = (
|
||||
float(command.get("x1", current[0])),
|
||||
float(command.get("y1", current[1])),
|
||||
)
|
||||
ctrl2 = (
|
||||
float(command.get("x2", current[0])),
|
||||
float(command.get("y2", current[1])),
|
||||
)
|
||||
end = (
|
||||
float(command.get("x", current[0])),
|
||||
float(command.get("y", current[1])),
|
||||
)
|
||||
pen.curveTo(ctrl1, ctrl2, end)
|
||||
update_bounds(ctrl1)
|
||||
update_bounds(ctrl2)
|
||||
update_bounds(end)
|
||||
current = end
|
||||
elif op == "Q" and current is not None:
|
||||
ctrl = (
|
||||
float(command.get("x1", current[0])),
|
||||
float(command.get("y1", current[1])),
|
||||
)
|
||||
end = (
|
||||
float(command.get("x", current[0])),
|
||||
float(command.get("y", current[1])),
|
||||
)
|
||||
c1, c2, end_point = quadratic_to_cubic(current, ctrl, end)
|
||||
pen.curveTo(c1, c2, end_point)
|
||||
update_bounds(ctrl)
|
||||
update_bounds(end_point)
|
||||
current = end_point
|
||||
elif op == "Z" and open_path:
|
||||
pen.closePath()
|
||||
open_path = False
|
||||
if start_point is not None:
|
||||
current = start_point
|
||||
# Ignore unsupported commands silently.
|
||||
|
||||
if open_path:
|
||||
pen.endPath()
|
||||
|
||||
charstring = pen.getCharString()
|
||||
bbox = None
|
||||
if bounds[0] <= bounds[2] and bounds[1] <= bounds[3]:
|
||||
bbox = (bounds[0], bounds[1], bounds[2], bounds[3])
|
||||
return charstring, bbox
|
||||
|
||||
|
||||
def build_ttf_glyph(glyph: GlyphSource, max_error: float) -> Optional[object]:
|
||||
pen = TTGlyphPen(glyphSet=None)
|
||||
draw_pen = Cu2QuPen(pen, max_error, reverse_direction=False)
|
||||
|
||||
current_exists = False
|
||||
|
||||
for command in glyph.outline:
|
||||
if not isinstance(command, dict):
|
||||
continue
|
||||
op = command.get("cmd")
|
||||
if op == "M":
|
||||
x = float(command.get("x", 0.0))
|
||||
y = float(command.get("y", 0.0))
|
||||
draw_pen.moveTo((x, y))
|
||||
current_exists = True
|
||||
elif op == "L" and current_exists:
|
||||
x = float(command.get("x", 0.0))
|
||||
y = float(command.get("y", 0.0))
|
||||
draw_pen.lineTo((x, y))
|
||||
elif op == "C" and current_exists:
|
||||
ctrl1 = (float(command.get("x1", 0.0)), float(command.get("y1", 0.0)))
|
||||
ctrl2 = (float(command.get("x2", 0.0)), float(command.get("y2", 0.0)))
|
||||
end = (float(command.get("x", 0.0)), float(command.get("y", 0.0)))
|
||||
draw_pen.curveTo(ctrl1, ctrl2, end)
|
||||
elif op == "Q" and current_exists:
|
||||
ctrl = (float(command.get("x1", 0.0)), float(command.get("y1", 0.0)))
|
||||
end = (float(command.get("x", 0.0)), float(command.get("y", 0.0)))
|
||||
draw_pen.qCurveTo(ctrl, end)
|
||||
elif op == "Z" and current_exists:
|
||||
draw_pen.closePath()
|
||||
current_exists = False
|
||||
|
||||
if current_exists:
|
||||
draw_pen.endPath()
|
||||
|
||||
try:
|
||||
glyph_obj = pen.glyph()
|
||||
except Exception:
|
||||
return None
|
||||
return glyph_obj
|
||||
|
||||
|
||||
def synthesise_fonts(
|
||||
data: Dict[str, object],
|
||||
otf_output: Path,
|
||||
ttf_output: Optional[Path],
|
||||
family_name: str,
|
||||
style_name: str,
|
||||
units_per_em: int,
|
||||
cu2qu_error: float,
|
||||
) -> None:
|
||||
_font_matrix = parse_font_matrix(data.get("fontMatrix"))
|
||||
glyphs = iterate_glyphs(data)
|
||||
|
||||
results: List[GlyphBuildResult] = []
|
||||
global_y_min = math.inf
|
||||
global_y_max = -math.inf
|
||||
|
||||
default_width = max(1, units_per_em // 2)
|
||||
|
||||
for glyph in glyphs:
|
||||
width = resolve_width(glyph.width, default_width)
|
||||
charstring, bounds = build_cff_charstring(glyph, width)
|
||||
ttf_glyph = None
|
||||
if ttf_output is not None:
|
||||
ttf_glyph = build_ttf_glyph(glyph, cu2qu_error)
|
||||
if ttf_glyph is not None:
|
||||
ttf_glyph.width = width
|
||||
if bounds is not None:
|
||||
global_y_min = min(global_y_min, bounds[1])
|
||||
global_y_max = max(global_y_max, bounds[3])
|
||||
results.append(
|
||||
GlyphBuildResult(
|
||||
name=glyph.name,
|
||||
width=width,
|
||||
charstring=charstring,
|
||||
ttf_glyph=ttf_glyph,
|
||||
unicode=glyph.unicode,
|
||||
char_code=glyph.char_code,
|
||||
bounds=bounds,
|
||||
)
|
||||
)
|
||||
|
||||
if not results:
|
||||
raise RuntimeError("No glyphs provided in input JSON")
|
||||
|
||||
ascent = global_y_max if math.isfinite(global_y_max) else units_per_em * 0.8
|
||||
descent = global_y_min if math.isfinite(global_y_min) else -units_per_em * 0.2
|
||||
ascent = otRound(ascent)
|
||||
descent = otRound(descent)
|
||||
if ascent <= 0:
|
||||
ascent = otRound(units_per_em * 0.8)
|
||||
if descent >= 0:
|
||||
descent = -otRound(units_per_em * 0.2)
|
||||
|
||||
glyph_order = [".notdef"] + [result.name for result in results]
|
||||
horizontal_metrics = {result.name: (result.width, 0) for result in results}
|
||||
horizontal_metrics[".notdef"] = (default_width, 0)
|
||||
|
||||
cmap: Dict[int, str] = {}
|
||||
next_private = 0xF000
|
||||
for result in results:
|
||||
code_point = result.unicode
|
||||
if code_point is None:
|
||||
raw_code = result.char_code
|
||||
if raw_code is not None:
|
||||
code_point = raw_code
|
||||
else:
|
||||
code_point = next_private
|
||||
next_private += 1
|
||||
cmap[code_point] = result.name
|
||||
|
||||
notdef_pen = T2CharStringPen(width=default_width, glyphSet=None)
|
||||
notdef_pen.endPath()
|
||||
charstrings = {result.name: result.charstring for result in results}
|
||||
charstrings[".notdef"] = notdef_pen.getCharString()
|
||||
|
||||
name_table_entries = {
|
||||
"familyName": family_name,
|
||||
"styleName": style_name,
|
||||
"psName": f"{family_name.replace(' ', '')}-{style_name}",
|
||||
"fullName": f"{family_name} {style_name}",
|
||||
}
|
||||
|
||||
# Build OTF (CFF) font.
|
||||
fb = FontBuilder(units_per_em, isTTF=False)
|
||||
fb.setupGlyphOrder(glyph_order)
|
||||
fb.setupCharacterMap(cmap)
|
||||
fb.setupHorizontalMetrics(horizontal_metrics)
|
||||
fb.setupHorizontalHeader(ascent=ascent, descent=descent)
|
||||
fb.setupOS2(
|
||||
sTypoAscender=ascent,
|
||||
sTypoDescender=descent,
|
||||
usWinAscent=max(ascent, 0),
|
||||
usWinDescent=abs(min(descent, 0)),
|
||||
sxHeight=otRound(units_per_em * 0.5),
|
||||
sCapHeight=otRound(units_per_em * 0.7),
|
||||
)
|
||||
fb.setupNameTable(name_table_entries)
|
||||
fb.setupPost()
|
||||
fb.setupCFF(
|
||||
name_table_entries["psName"],
|
||||
{
|
||||
"FullName": name_table_entries["fullName"],
|
||||
"FamilyName": name_table_entries["familyName"],
|
||||
"Weight": style_name,
|
||||
},
|
||||
charstrings,
|
||||
{"BlueValues": []},
|
||||
)
|
||||
fb.font.save(str(otf_output))
|
||||
|
||||
if ttf_output is None:
|
||||
return
|
||||
|
||||
glyph_objects: Dict[str, object] = {}
|
||||
empty_pen = TTGlyphPen(None)
|
||||
empty_pen.moveTo((0, 0))
|
||||
empty_pen.lineTo((0, 0))
|
||||
empty_pen.closePath()
|
||||
empty_glyph = empty_pen.glyph()
|
||||
empty_glyph.width = default_width
|
||||
glyph_objects[".notdef"] = empty_glyph
|
||||
for result in results:
|
||||
glyph_obj = result.ttf_glyph
|
||||
if glyph_obj is None:
|
||||
temp_pen = TTGlyphPen(None)
|
||||
temp_pen.moveTo((0, 0))
|
||||
temp_pen.lineTo((0, 0))
|
||||
temp_pen.closePath()
|
||||
glyph_obj = temp_pen.glyph()
|
||||
glyph_obj.width = result.width
|
||||
glyph_objects[result.name] = glyph_obj
|
||||
|
||||
ttf_fb = FontBuilder(units_per_em, isTTF=True)
|
||||
ttf_fb.setupGlyphOrder(glyph_order)
|
||||
ttf_fb.setupCharacterMap(cmap)
|
||||
ttf_fb.setupHorizontalMetrics(horizontal_metrics)
|
||||
ttf_fb.setupHorizontalHeader(ascent=ascent, descent=descent)
|
||||
ttf_fb.setupOS2(
|
||||
sTypoAscender=ascent,
|
||||
sTypoDescender=descent,
|
||||
usWinAscent=max(ascent, 0),
|
||||
usWinDescent=abs(min(descent, 0)),
|
||||
sxHeight=otRound(units_per_em * 0.5),
|
||||
sCapHeight=otRound(units_per_em * 0.7),
|
||||
)
|
||||
ttf_fb.setupNameTable(name_table_entries)
|
||||
ttf_fb.setupPost()
|
||||
ttf_fb.setupGlyf(glyph_objects)
|
||||
ttf_fb.setupDummyDSIG()
|
||||
ttf_fb.font.save(str(ttf_output))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
input_path = Path(args.input).resolve()
|
||||
otf_output = Path(args.otf_output).resolve()
|
||||
ttf_output = Path(args.ttf_output).resolve() if args.ttf_output else None
|
||||
|
||||
data = load_json(input_path)
|
||||
try:
|
||||
synthesise_fonts(
|
||||
data=data,
|
||||
otf_output=otf_output,
|
||||
ttf_output=ttf_output,
|
||||
family_name=args.family_name,
|
||||
style_name=args.style_name,
|
||||
units_per_em=args.units_per_em,
|
||||
cu2qu_error=args.cu2qu_error,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Failed to generate fonts: {exc}", file=sys.stderr)
|
||||
if otf_output.exists():
|
||||
otf_output.unlink()
|
||||
if ttf_output and ttf_output.exists():
|
||||
ttf_output.unlink()
|
||||
sys.exit(1)
|
||||
|
||||
message = f"Generated font at {otf_output}"
|
||||
if ttf_output:
|
||||
message += f" and {ttf_output}"
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Synchronize Type3 library index entries with captured signature dumps.
|
||||
|
||||
The script scans docs/type3/signatures/*.json (or a custom --signatures-dir),
|
||||
matches each font by alias/signature to app/core/src/main/resources/type3/library/index.json,
|
||||
and updates the entry's signatures / glyphCoverage / aliases / source fields.
|
||||
|
||||
Usage:
|
||||
scripts/update_type3_library.py --apply
|
||||
|
||||
Run without --apply to see a dry-run summary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_SIGNATURES = REPO_ROOT / "docs" / "type3" / "signatures"
|
||||
DEFAULT_INDEX = (
|
||||
REPO_ROOT / "app" / "core" / "src" / "main" / "resources" / "type3" / "library" / "index.json"
|
||||
)
|
||||
|
||||
|
||||
def normalize_alias(value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
trimmed = value.strip()
|
||||
plus = trimmed.find("+")
|
||||
if plus >= 0 and plus < len(trimmed) - 1:
|
||||
trimmed = trimmed[plus + 1 :]
|
||||
lowered = trimmed.lower()
|
||||
return lowered if lowered else None
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def dump_json(path: Path, data) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def iter_signature_fonts(signature_file: Path):
|
||||
payload = load_json(signature_file)
|
||||
pdf_source = payload.get("pdf")
|
||||
for font in payload.get("fonts", []):
|
||||
alias = font.get("alias") or font.get("baseName")
|
||||
normalized = normalize_alias(alias) or normalize_alias(font.get("baseName"))
|
||||
yield {
|
||||
"alias_raw": alias,
|
||||
"alias": normalized,
|
||||
"baseName": font.get("baseName"),
|
||||
"signature": font.get("signature"),
|
||||
"glyphCoverage": font.get("glyphCoverage") or [],
|
||||
"pdf": pdf_source,
|
||||
"file": signature_file,
|
||||
}
|
||||
|
||||
|
||||
def make_alias_index(entries: List[Dict]) -> Tuple[Dict[str, Dict], Dict[str, Dict]]:
|
||||
alias_index: Dict[str, Dict] = {}
|
||||
signature_index: Dict[str, Dict] = {}
|
||||
for entry in entries:
|
||||
for alias in entry.get("aliases", []) or []:
|
||||
normalized = normalize_alias(alias)
|
||||
if normalized:
|
||||
alias_index.setdefault(normalized, entry)
|
||||
base_name_alias = normalize_alias(entry.get("label"))
|
||||
if base_name_alias:
|
||||
alias_index.setdefault(base_name_alias, entry)
|
||||
for signature in entry.get("signatures", []) or []:
|
||||
signature_index.setdefault(signature.lower(), entry)
|
||||
return alias_index, signature_index
|
||||
|
||||
|
||||
def ensure_list(container: Dict, key: str) -> List:
|
||||
value = container.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
value = []
|
||||
container[key] = value
|
||||
return value
|
||||
|
||||
|
||||
def merge_sorted_unique(values: Iterable[int]) -> List[int]:
|
||||
return sorted({int(v) for v in values if isinstance(v, int)})
|
||||
|
||||
|
||||
def normalize_source_path(pdf_path: Optional[str]) -> Optional[str]:
|
||||
if not pdf_path:
|
||||
return None
|
||||
try:
|
||||
source = Path(pdf_path)
|
||||
rel = source.relative_to(REPO_ROOT)
|
||||
except Exception:
|
||||
rel = Path(pdf_path)
|
||||
return str(rel).replace("\\", "/")
|
||||
|
||||
|
||||
def update_library(
|
||||
signatures_dir: Path, index_path: Path, apply_changes: bool
|
||||
) -> Tuple[int, int, List[Tuple[str, Path]]]:
|
||||
entries = load_json(index_path)
|
||||
alias_index, signature_index = make_alias_index(entries)
|
||||
|
||||
modifications = 0
|
||||
updated_entries = set()
|
||||
unmatched: List[Tuple[str, Path]] = []
|
||||
|
||||
signature_files = sorted(signatures_dir.glob("*.json"))
|
||||
if not signature_files:
|
||||
print(f"No signature JSON files found under {signatures_dir}", file=sys.stderr)
|
||||
return 0, 0, unmatched
|
||||
|
||||
for sig_file in signature_files:
|
||||
for font in iter_signature_fonts(sig_file):
|
||||
signature = font["signature"]
|
||||
norm_signature = signature.lower() if signature else None
|
||||
alias = font["alias"]
|
||||
|
||||
entry = None
|
||||
if norm_signature and norm_signature in signature_index:
|
||||
entry = signature_index[norm_signature]
|
||||
elif alias and alias in alias_index:
|
||||
entry = alias_index[alias]
|
||||
|
||||
if entry is None:
|
||||
unmatched.append((font.get("baseName") or font.get("alias_raw") or "unknown", sig_file))
|
||||
continue
|
||||
|
||||
entry_modified = False
|
||||
|
||||
# Signatures
|
||||
if signature:
|
||||
signature_list = ensure_list(entry, "signatures")
|
||||
if signature not in signature_list:
|
||||
signature_list.append(signature)
|
||||
entry_modified = True
|
||||
signature_index[signature.lower()] = entry
|
||||
|
||||
# Aliases
|
||||
alias_raw = font.get("alias_raw")
|
||||
if alias_raw:
|
||||
aliases = ensure_list(entry, "aliases")
|
||||
if alias_raw not in aliases:
|
||||
aliases.append(alias_raw)
|
||||
entry_modified = True
|
||||
normalized = normalize_alias(alias_raw)
|
||||
if normalized:
|
||||
alias_index.setdefault(normalized, entry)
|
||||
|
||||
# Glyph coverage
|
||||
coverage = font.get("glyphCoverage") or []
|
||||
if coverage:
|
||||
existing = set(entry.get("glyphCoverage", []))
|
||||
merged = merge_sorted_unique(list(existing) + coverage)
|
||||
if merged != entry.get("glyphCoverage"):
|
||||
entry["glyphCoverage"] = merged
|
||||
entry_modified = True
|
||||
|
||||
# Source PDF
|
||||
pdf_source = normalize_source_path(font.get("pdf"))
|
||||
if pdf_source and not entry.get("source"):
|
||||
entry["source"] = pdf_source
|
||||
entry_modified = True
|
||||
|
||||
if entry_modified:
|
||||
modifications += 1
|
||||
updated_entries.add(entry.get("id", "<unknown>"))
|
||||
|
||||
if apply_changes and modifications > 0:
|
||||
dump_json(index_path, entries)
|
||||
|
||||
return modifications, len(updated_entries), unmatched
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Update Type3 library index using signature dumps.")
|
||||
parser.add_argument(
|
||||
"--signatures-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_SIGNATURES,
|
||||
help=f"Directory containing signature JSON files (default: {DEFAULT_SIGNATURES})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index",
|
||||
type=Path,
|
||||
default=DEFAULT_INDEX,
|
||||
help=f"Path to type3/library/index.json (default: {DEFAULT_INDEX})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Write changes back to the index file. Without this flag the script runs in dry-run mode.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
signatures_dir = args.signatures_dir if args.signatures_dir.is_absolute() else (REPO_ROOT / args.signatures_dir)
|
||||
index_path = args.index if args.index.is_absolute() else (REPO_ROOT / args.index)
|
||||
|
||||
if not signatures_dir.exists():
|
||||
print(f"Signature directory not found: {signatures_dir}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if not index_path.exists():
|
||||
print(f"Index file not found: {index_path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
modifications, updated_entries, unmatched = update_library(
|
||||
signatures_dir, index_path, apply_changes=args.apply
|
||||
)
|
||||
|
||||
mode = "APPLIED" if args.apply else "DRY-RUN"
|
||||
print(
|
||||
f"[{mode}] Processed signatures under {signatures_dir}. "
|
||||
f"Updated entries: {updated_entries}, individual modifications: {modifications}."
|
||||
)
|
||||
|
||||
if unmatched:
|
||||
print("\nUnmatched fonts (no library entry yet):")
|
||||
for alias, sig_file in unmatched:
|
||||
print(f" - {alias} (from {sig_file})")
|
||||
print("Add these fonts to index.json with the proper payload before rerunning.")
|
||||
|
||||
if modifications == 0:
|
||||
print("No changes detected; index.json already matches captured signatures.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user