feat(fonts): add Linux font support for ASS/SSA subtitles

Implements cross-platform font discovery and intelligent fallback system for ASS/SSA subtitle rendering on Linux/macOS systems.

Windows support has not been tested
This commit is contained in:
Andy
2025-11-03 20:23:45 +00:00
parent f00790f31b
commit 8b0b3045e3
4 changed files with 441 additions and 37 deletions

View File

@@ -31,6 +31,7 @@ dependencies = [
"click>=8.1.8,<9", "click>=8.1.8,<9",
"construct>=2.8.8,<3", "construct>=2.8.8,<3",
"crccheck>=1.3.0,<2", "crccheck>=1.3.0,<2",
"fonttools>=4.0.0,<5",
"jsonpickle>=3.0.4,<4", "jsonpickle>=3.0.4,<4",
"langcodes>=3.4.0,<4", "langcodes>=3.4.0,<4",
"lxml>=5.2.1,<7", "lxml>=5.2.1,<7",
@@ -60,6 +61,7 @@ dependencies = [
"subby", "subby",
"aiohttp-swagger3>=0.9.0,<1", "aiohttp-swagger3>=0.9.0,<1",
"pysubs2>=1.7.0,<2", "pysubs2>=1.7.0,<2",
"PyExecJS>=1.5.1,<2",
] ]
[project.urls] [project.urls]

View File

@@ -57,8 +57,8 @@ from unshackle.core.titles.episode import Episode
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.utilities import (get_debug_logger, get_system_fonts, init_debug_logger, is_close_match, from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
time_elapsed_since) is_close_match, suggest_font_packages, time_elapsed_since)
from unshackle.core.utils import tags from unshackle.core.utils import tags
from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice, from unshackle.core.utils.click_types import (LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, ContextData, MultipleChoice,
SubtitleCodecChoice, VideoCodecChoice) SubtitleCodecChoice, VideoCodecChoice)
@@ -69,7 +69,7 @@ from unshackle.core.vaults import Vaults
class dl: class dl:
@staticmethod @staticmethod
def _truncate_pssh_for_display(pssh_string: str, drm_type: str) -> str: def truncate_pssh_for_display(pssh_string: str, drm_type: str) -> str:
"""Truncate PSSH string for display when not in debug mode.""" """Truncate PSSH string for display when not in debug mode."""
if logging.root.level == logging.DEBUG or not pssh_string: if logging.root.level == logging.DEBUG or not pssh_string:
return pssh_string return pssh_string
@@ -80,6 +80,115 @@ class dl:
return pssh_string[: max_width - 3] + "..." return pssh_string[: max_width - 3] + "..."
def find_custom_font(self, font_name: str) -> Optional[Path]:
"""
Find font in custom fonts directory.
Args:
font_name: Font family name to find
Returns:
Path to font file, or None if not found
"""
family_dir = Path(config.directories.fonts, font_name)
if family_dir.exists():
fonts = list(family_dir.glob("*.*tf"))
return fonts[0] if fonts else None
return None
def prepare_temp_font(
self,
font_name: str,
matched_font: Path,
system_fonts: dict[str, Path],
temp_font_files: list[Path]
) -> Path:
"""
Copy system font to temp and log if using fallback.
Args:
font_name: Requested font name
matched_font: Path to matched system font
system_fonts: Dictionary of available system fonts
temp_font_files: List to track temp files for cleanup
Returns:
Path to temp font file
"""
# Find the matched name for logging
matched_name = next(
(name for name, path in system_fonts.items() if path == matched_font),
None
)
if matched_name and matched_name.lower() != font_name.lower():
self.log.info(f"Using '{matched_name}' as fallback for '{font_name}'")
# Create unique temp file path
safe_name = font_name.replace(" ", "_").replace("/", "_")
temp_path = config.directories.temp / f"font_{safe_name}{matched_font.suffix}"
# Copy if not already exists
if not temp_path.exists():
shutil.copy2(matched_font, temp_path)
temp_font_files.append(temp_path)
return temp_path
def _attach_subtitle_fonts(
self,
font_names: list[str],
title: Title_T,
temp_font_files: list[Path]
) -> tuple[int, list[str]]:
"""
Attach fonts for subtitle rendering.
Args:
font_names: List of font names requested by subtitles
title: Title object to attach fonts to
temp_font_files: List to track temp files for cleanup
Returns:
Tuple of (fonts_attached_count, missing_fonts_list)
"""
system_fonts = get_system_fonts()
self.log.info(f"Discovered {len(system_fonts)} system font families")
font_count = 0
missing_fonts = []
for font_name in set(font_names):
# Try custom fonts first
if custom_font := self.find_custom_font(font_name):
title.tracks.add(Attachment(path=custom_font, name=f"{font_name} ({custom_font.stem})"))
font_count += 1
continue
# Try system fonts with fallback
if system_font := find_font_with_fallbacks(font_name, system_fonts):
temp_path = self.prepare_temp_font(font_name, system_font, system_fonts, temp_font_files)
title.tracks.add(Attachment(path=temp_path, name=f"{font_name} ({system_font.stem})"))
font_count += 1
else:
self.log.warning(f"Subtitle uses font '{font_name}' but it could not be found")
missing_fonts.append(font_name)
return font_count, missing_fonts
def _suggest_missing_fonts(self, missing_fonts: list[str]) -> None:
"""
Show package installation suggestions for missing fonts.
Args:
missing_fonts: List of font names that couldn't be found
"""
if suggestions := suggest_font_packages(missing_fonts):
self.log.info("Install font packages to improve subtitle rendering:")
for package_cmd, fonts in suggestions.items():
self.log.info(f" $ sudo apt install {package_cmd}")
self.log.info(f" → Provides: {', '.join(fonts)}")
@click.command( @click.command(
short_help="Download, Decrypt, and Mux tracks for titles from a Service.", short_help="Download, Decrypt, and Mux tracks for titles from a Service.",
cls=Services, cls=Services,
@@ -794,6 +903,7 @@ class dl:
continue continue
console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2))) console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2)))
temp_font_files = []
if isinstance(title, Episode) and not self.tmdb_searched: if isinstance(title, Episode) and not self.tmdb_searched:
kind = "tv" kind = "tv"
@@ -1435,26 +1545,16 @@ class dl:
if line.startswith("Style: "): if line.startswith("Style: "):
font_names.append(line.removesuffix("Style: ").split(",")[1]) font_names.append(line.removesuffix("Style: ").split(",")[1])
font_count = 0 font_count, missing_fonts = self._attach_subtitle_fonts(
system_fonts = get_system_fonts() font_names, title, temp_font_files
for font_name in set(font_names): )
family_dir = Path(config.directories.fonts, font_name)
fonts_from_system = [file for name, file in system_fonts.items() if name.startswith(font_name)]
if family_dir.exists():
fonts = family_dir.glob("*.*tf")
for font in fonts:
title.tracks.add(Attachment(path=font, name=f"{font_name} ({font.stem})"))
font_count += 1
elif fonts_from_system:
for font in fonts_from_system:
title.tracks.add(Attachment(path=font, name=f"{font_name} ({font.stem})"))
font_count += 1
else:
self.log.warning(f"Subtitle uses font [text2]{font_name}[/] but it could not be found...")
if font_count: if font_count:
self.log.info(f"Attached {font_count} fonts for the Subtitles") self.log.info(f"Attached {font_count} fonts for the Subtitles")
if missing_fonts and sys.platform != "win32":
self._suggest_missing_fonts(missing_fonts)
# Handle DRM decryption BEFORE repacking (must decrypt first!) # Handle DRM decryption BEFORE repacking (must decrypt first!)
service_name = service.__class__.__name__.upper() service_name = service.__class__.__name__.upper()
decryption_method = config.decryption_map.get(service_name, config.decryption) decryption_method = config.decryption_map.get(service_name, config.decryption)
@@ -1608,8 +1708,17 @@ class dl:
video_track.delete() video_track.delete()
for track in title.tracks: for track in title.tracks:
track.delete() track.delete()
# Clear temp font attachment paths and delete other attachments
for attachment in title.tracks.attachments: for attachment in title.tracks.attachments:
attachment.delete() if attachment.path and attachment.path in temp_font_files:
attachment.path = None
else:
attachment.delete()
# Clean up temp fonts
for temp_path in temp_font_files:
temp_path.unlink(missing_ok=True)
else: else:
# dont mux # dont mux
@@ -1752,7 +1861,7 @@ class dl:
) )
with self.DRM_TABLE_LOCK: with self.DRM_TABLE_LOCK:
pssh_display = self._truncate_pssh_for_display(drm.pssh.dumps(), "Widevine") pssh_display = self.truncate_pssh_for_display(drm.pssh.dumps(), "Widevine")
cek_tree = Tree(Text.assemble(("Widevine", "cyan"), (f"({pssh_display})", "text"), overflow="fold")) cek_tree = Tree(Text.assemble(("Widevine", "cyan"), (f"({pssh_display})", "text"), overflow="fold"))
pre_existing_tree = next( pre_existing_tree = next(
(x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None (x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None
@@ -1921,7 +2030,7 @@ class dl:
) )
with self.DRM_TABLE_LOCK: with self.DRM_TABLE_LOCK:
pssh_display = self._truncate_pssh_for_display(drm.pssh_b64 or "", "PlayReady") pssh_display = self.truncate_pssh_for_display(drm.pssh_b64 or "", "PlayReady")
cek_tree = Tree( cek_tree = Tree(
Text.assemble( Text.assemble(
("PlayReady", "cyan"), ("PlayReady", "cyan"),

View File

@@ -21,6 +21,7 @@ from uuid import uuid4
import chardet import chardet
import requests import requests
from construct import ValidationError from construct import ValidationError
from fontTools import ttLib
from langcodes import Language, closest_match from langcodes import Language, closest_match
from pymp4.parser import Box from pymp4.parser import Box
from unidecode import unidecode from unidecode import unidecode
@@ -29,6 +30,30 @@ from unshackle.core.cacher import Cacher
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE from unshackle.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE
"""
Utility functions for the unshackle media archival tool.
This module provides various utility functions including:
- Font discovery and fallback system for subtitle rendering
- Cross-platform system font scanning with Windows → Linux font family mapping
- Log file management and rotation
- IP geolocation with caching and provider rotation
- Language matching utilities
- MP4/ISOBMFF box parsing
- File sanitization and path handling
- Structured JSON debug logging
Font System:
The font subsystem enables cross-platform font discovery for ASS/SSA subtitles.
On Linux, it scans standard font directories and maps Windows font names (Arial,
Times New Roman) to their Linux equivalents (Liberation Sans, Liberation Serif).
Main Font Functions:
- get_system_fonts(): Discover installed fonts across platforms
- find_font_with_fallbacks(): Match fonts with intelligent fallback strategies
- suggest_font_packages(): Recommend packages to install for missing fonts
"""
def rotate_log_file(log_path: Path, keep: int = 20) -> Path: def rotate_log_file(log_path: Path, keep: int = 20) -> Path:
""" """
@@ -428,21 +453,254 @@ def get_extension(value: Union[str, Path, ParseResult]) -> Optional[str]:
return ext return ext
def get_system_fonts() -> dict[str, Path]: def extract_font_family(font_path: Path) -> Optional[str]:
if sys.platform == "win32": """
import winreg Extract font family name from TTF/OTF file using fontTools.
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg: Args:
key = winreg.OpenKey(reg, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ) font_path: Path to the font file
total_fonts = winreg.QueryInfoKey(key)[1]
return { Returns:
name.replace(" (TrueType)", ""): Path(r"C:\Windows\Fonts", filename) Font family name if successfully extracted, None otherwise
for n in range(0, total_fonts) """
for name, filename, _ in [winreg.EnumValue(key, n)] try:
} font = ttLib.TTFont(font_path)
else: name_table = font["name"]
# TODO: Get System Fonts for Linux and mac OS
return {} # Try to get family name (nameID 1) for Windows platform (platformID 3)
# This matches the naming convention used in Windows registry
for record in name_table.names:
if record.nameID == 1 and record.platformID == 3:
return record.toUnicode()
# Fallback to other platforms if Windows name not found
for record in name_table.names:
if record.nameID == 1:
return record.toUnicode()
except Exception:
# Silently ignore font parsing errors (corrupted fonts, etc.)
pass
return None
def _get_windows_fonts() -> dict[str, Path]:
"""
Get fonts from Windows registry.
Returns:
Dictionary mapping font family names to their file paths
"""
import winreg
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as reg:
key = winreg.OpenKey(reg, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", 0, winreg.KEY_READ)
total_fonts = winreg.QueryInfoKey(key)[1]
return {
name.replace(" (TrueType)", ""): Path(r"C:\Windows\Fonts", filename)
for n in range(0, total_fonts)
for name, filename, _ in [winreg.EnumValue(key, n)]
}
def _scan_font_directory(font_dir: Path, fonts: dict[str, Path], log: logging.Logger) -> None:
"""
Scan a single directory for fonts.
Args:
font_dir: Directory to scan
fonts: Dictionary to populate with found fonts
log: Logger instance for error reporting
"""
font_files = list(font_dir.rglob("*.ttf")) + list(font_dir.rglob("*.otf"))
for font_file in font_files:
try:
if family_name := extract_font_family(font_file):
if family_name not in fonts:
fonts[family_name] = font_file
except Exception as e:
log.debug(f"Failed to process {font_file}: {e}")
def _get_unix_fonts() -> dict[str, Path]:
"""
Get fonts from Linux/macOS standard directories.
Returns:
Dictionary mapping font family names to their file paths
"""
log = logging.getLogger("get_system_fonts")
fonts = {}
font_dirs = [
Path("/usr/share/fonts"),
Path("/usr/local/share/fonts"),
Path.home() / ".fonts",
Path.home() / ".local/share/fonts",
]
for font_dir in font_dirs:
if not font_dir.exists():
continue
try:
_scan_font_directory(font_dir, fonts, log)
except Exception as e:
log.warning(f"Failed to scan {font_dir}: {e}")
log.debug(f"Discovered {len(fonts)} system font families")
return fonts
def get_system_fonts() -> dict[str, Path]:
"""
Get system fonts as a mapping of font family names to font file paths.
On Windows: Uses registry to get font display names
On Linux/macOS: Scans standard font directories and extracts family names using fontTools
Returns:
Dictionary mapping font family names to their file paths
"""
if sys.platform == "win32":
return _get_windows_fonts()
return _get_unix_fonts()
# Common Windows font names mapped to their Linux equivalents
# Ordered by preference (first match is used)
FONT_ALIASES = {
"Arial": ["Liberation Sans", "DejaVu Sans", "Nimbus Sans", "FreeSans"],
"Arial Black": ["Liberation Sans", "DejaVu Sans", "Nimbus Sans"],
"Arial Bold": ["Liberation Sans", "DejaVu Sans"],
"Arial Unicode MS": ["DejaVu Sans", "Noto Sans", "FreeSans"],
"Times New Roman": ["Liberation Serif", "DejaVu Serif", "Nimbus Roman", "FreeSerif"],
"Courier New": ["Liberation Mono", "DejaVu Sans Mono", "Nimbus Mono PS", "FreeMono"],
"Comic Sans MS": ["Comic Neue", "Comic Relief", "DejaVu Sans"],
"Georgia": ["Gelasio", "DejaVu Serif", "Liberation Serif"],
"Impact": ["Impact", "Anton", "Liberation Sans"],
"Trebuchet MS": ["Ubuntu", "DejaVu Sans", "Liberation Sans"],
"Verdana": ["DejaVu Sans", "Bitstream Vera Sans", "Liberation Sans"],
"Tahoma": ["DejaVu Sans", "Liberation Sans"],
"Adobe Arabic": ["Noto Sans Arabic", "DejaVu Sans"],
"Noto Sans Thai": ["Noto Sans Thai", "Noto Sans"],
}
def find_case_insensitive(font_name: str, fonts: dict[str, Path]) -> Optional[Path]:
"""
Find font by case-insensitive name match.
Args:
font_name: Font family name to find
fonts: Dictionary of available fonts
Returns:
Path to matched font, or None if not found
"""
font_lower = font_name.lower()
for name, path in fonts.items():
if name.lower() == font_lower:
return path
return None
def find_font_with_fallbacks(font_name: str, system_fonts: dict[str, Path]) -> Optional[Path]:
"""
Find a font by name with intelligent fallback matching.
Tries multiple strategies in order:
1. Exact match (case-sensitive)
2. Case-insensitive match
3. Alias lookup (Windows → Linux font equivalents)
4. Partial/prefix match
Args:
font_name: The requested font family name (e.g., "Arial", "Times New Roman")
system_fonts: Dictionary of available fonts (family name → path)
Returns:
Path to the matched font file, or None if no match found
"""
if not system_fonts:
return None
# Strategy 1: Exact match (case-sensitive)
if font_name in system_fonts:
return system_fonts[font_name]
# Strategy 2: Case-insensitive match
if result := find_case_insensitive(font_name, system_fonts):
return result
# Strategy 3: Alias lookup
if font_name in FONT_ALIASES:
for alias in FONT_ALIASES[font_name]:
# Try exact match for alias
if alias in system_fonts:
return system_fonts[alias]
# Try case-insensitive match for alias
if result := find_case_insensitive(alias, system_fonts):
return result
# Strategy 4: Partial/prefix match as last resort
font_name_lower = font_name.lower()
for name, path in system_fonts.items():
if name.lower().startswith(font_name_lower):
return path
return None
# Mapping of font families to system packages that provide them
FONT_PACKAGES = {
"liberation": {
"debian": "fonts-liberation fonts-liberation2",
"fonts": ["Liberation Sans", "Liberation Serif", "Liberation Mono"],
},
"dejavu": {
"debian": "fonts-dejavu fonts-dejavu-core fonts-dejavu-extra",
"fonts": ["DejaVu Sans", "DejaVu Serif", "DejaVu Sans Mono"],
},
"noto": {
"debian": "fonts-noto fonts-noto-core",
"fonts": ["Noto Sans", "Noto Serif", "Noto Sans Mono", "Noto Sans Arabic", "Noto Sans Thai"],
},
"ubuntu": {
"debian": "fonts-ubuntu",
"fonts": ["Ubuntu", "Ubuntu Mono"],
},
}
def suggest_font_packages(missing_fonts: list[str]) -> dict[str, list[str]]:
"""
Suggest system packages to install for missing fonts.
Args:
missing_fonts: List of font family names that couldn't be found
Returns:
Dictionary mapping package names to lists of fonts they would provide
"""
suggestions = {}
# Check which fonts from aliases would help
needed_aliases = set()
for font in missing_fonts:
if font in FONT_ALIASES:
needed_aliases.update(FONT_ALIASES[font])
# Map needed aliases to packages
for package_name, package_info in FONT_PACKAGES.items():
provided_fonts = package_info["fonts"]
matching_fonts = [f for f in provided_fonts if f in needed_aliases]
if matching_fonts:
suggestions[package_info["debian"]] = matching_fonts
return suggestions
class FPS(ast.NodeVisitor): class FPS(ast.NodeVisitor):

37
uv.lock generated
View File

@@ -502,6 +502,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
] ]
[[package]]
name = "fonttools"
version = "4.60.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/70/03e9d89a053caff6ae46053890eba8e4a5665a7c5638279ed4492e6d4b8b/fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28", size = 2810747, upload-time = "2025-09-29T21:10:59.653Z" },
{ url = "https://files.pythonhosted.org/packages/6f/41/449ad5aff9670ab0df0f61ee593906b67a36d7e0b4d0cd7fa41ac0325bf5/fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15", size = 2346909, upload-time = "2025-09-29T21:11:02.882Z" },
{ url = "https://files.pythonhosted.org/packages/9a/18/e5970aa96c8fad1cb19a9479cc3b7602c0c98d250fcdc06a5da994309c50/fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c", size = 4864572, upload-time = "2025-09-29T21:11:05.096Z" },
{ url = "https://files.pythonhosted.org/packages/ce/20/9b2b4051b6ec6689480787d506b5003f72648f50972a92d04527a456192c/fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea", size = 4794635, upload-time = "2025-09-29T21:11:08.651Z" },
{ url = "https://files.pythonhosted.org/packages/10/52/c791f57347c1be98f8345e3dca4ac483eb97666dd7c47f3059aeffab8b59/fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652", size = 4843878, upload-time = "2025-09-29T21:11:10.893Z" },
{ url = "https://files.pythonhosted.org/packages/69/e9/35c24a8d01644cee8c090a22fad34d5b61d1e0a8ecbc9945ad785ebf2e9e/fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a", size = 4954555, upload-time = "2025-09-29T21:11:13.24Z" },
{ url = "https://files.pythonhosted.org/packages/f7/86/fb1e994971be4bdfe3a307de6373ef69a9df83fb66e3faa9c8114893d4cc/fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce", size = 2232019, upload-time = "2025-09-29T21:11:15.73Z" },
{ url = "https://files.pythonhosted.org/packages/40/84/62a19e2bd56f0e9fb347486a5b26376bade4bf6bbba64dda2c103bd08c94/fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038", size = 2276803, upload-time = "2025-09-29T21:11:18.152Z" },
{ url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" },
{ url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" },
{ url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" },
{ url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" },
{ url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" },
{ url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" },
{ url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" },
{ url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" },
{ url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" },
]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.7.0" version = "1.7.0"
@@ -1581,6 +1614,7 @@ dependencies = [
{ name = "crccheck" }, { name = "crccheck" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "curl-cffi" }, { name = "curl-cffi" },
{ name = "fonttools" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jsonpickle" }, { name = "jsonpickle" },
{ name = "langcodes" }, { name = "langcodes" },
@@ -1633,6 +1667,7 @@ requires-dist = [
{ name = "crccheck", specifier = ">=1.3.0,<2" }, { name = "crccheck", specifier = ">=1.3.0,<2" },
{ name = "cryptography", specifier = ">=45.0.0" }, { name = "cryptography", specifier = ">=45.0.0" },
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.8" }, { name = "curl-cffi", specifier = ">=0.7.0b4,<0.8" },
{ name = "fonttools", specifier = ">=4.0.0,<5" },
{ name = "httpx", specifier = ">=0.28.1,<0.29" }, { name = "httpx", specifier = ">=0.28.1,<0.29" },
{ name = "jsonpickle", specifier = ">=3.0.4,<4" }, { name = "jsonpickle", specifier = ">=3.0.4,<4" },
{ name = "langcodes", specifier = ">=3.4.0,<4" }, { name = "langcodes", specifier = ">=3.4.0,<4" },
@@ -1641,7 +1676,7 @@ requires-dist = [
{ name = "protobuf", specifier = ">=4.25.3,<5" }, { name = "protobuf", specifier = ">=4.25.3,<5" },
{ name = "pycaption", specifier = ">=2.2.6,<3" }, { name = "pycaption", specifier = ">=2.2.6,<3" },
{ name = "pycryptodomex", specifier = ">=3.20.0,<4" }, { name = "pycryptodomex", specifier = ">=3.20.0,<4" },
{ name = "pyexecjs", specifier = ">=1.5.1" }, { name = "pyexecjs", specifier = ">=1.5.1,<2" },
{ name = "pyjwt", specifier = ">=2.8.0,<3" }, { name = "pyjwt", specifier = ">=2.8.0,<3" },
{ name = "pymediainfo", specifier = ">=6.1.0,<7" }, { name = "pymediainfo", specifier = ">=6.1.0,<7" },
{ name = "pymp4", specifier = ">=1.4.0,<2" }, { name = "pymp4", specifier = ">=1.4.0,<2" },