From 70ee43f8b43853fdff9e96694d433c473080beba Mon Sep 17 00:00:00 2001 From: xMohnad Date: Tue, 28 Jul 2026 16:04:06 +0300 Subject: [PATCH] feat: add subtitle font inspection screen with ASS/SSA parser --- src/pyinkr/dialogs.py | 61 ++++++++++- src/pyinkr/fonts.py | 239 ++++++++++++++++++++++++++++++++++++++++++ src/pyinkr/style.tcss | 32 ++++++ src/pyinkr/widgets.py | 19 +++- 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/pyinkr/fonts.py diff --git a/src/pyinkr/dialogs.py b/src/pyinkr/dialogs.py index bc72e1a..a2a0c29 100644 --- a/src/pyinkr/dialogs.py +++ b/src/pyinkr/dialogs.py @@ -2,19 +2,23 @@ from typing import TYPE_CHECKING +from rich.text import Text from textual import on from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Center, Container, Horizontal, Middle from textual.screen import ModalScreen -from textual.widgets import Button, Footer, Header, Input, ProgressBar +from textual.widgets import Button, DataTable, Footer, Header, Input, ProgressBar, Static from typing_extensions import override if TYPE_CHECKING: + from collections.abc import Sequence from typing import ClassVar from textual.binding import BindingType + from pyinkr.fonts import FontUsage, SubtitleFontInfo + class EditScreen(ModalScreen[str | None]): """A modal screen for editing information.""" @@ -183,3 +187,58 @@ def update(self, progress: int) -> None: self.progress_bar.update(progress=progress) if progress >= self._total: self.dismiss(None) + + +class FontsScreen(ModalScreen[None]): + """A modal screen listing the fonts a subtitle track needs.""" + + BINDINGS: ClassVar[list[BindingType]] = [Binding("escape,enter,space", "close", "Close")] + + def __init__( + self, + info: SubtitleFontInfo, + title: str = "Fonts Used", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + """Initialize the FontsScreen with the fonts to display.""" + super().__init__(name=name, id=id, classes=classes) + self._info: SubtitleFontInfo = info + self._title: str = title + + @override + def compose(self) -> ComposeResult: # noqa: D102 (pure yield chain, no non-obvious behavior) + yield Header() + with Container(id="fonts-container") as container: + container.border_title = self._title + container.border_subtitle = f"{len(self._info.usages)} variant(s)" + if self._info.has_embedded_fonts: + yield Static("This subtitle already embeds its own fonts.", id="fonts-note") + if self._info.usages: + yield DataTable(id="fonts-table", cursor_type="row", zebra_stripes=True) + else: + with Center(): + with Middle(): + yield Static("No fonts found in this subtitle.") + yield Footer() + + def on_mount(self) -> None: + """Populate the table once mounted.""" + if usages := self._info.usages: + table = self.query_one("#fonts-table", DataTable) + table.add_columns("Font", "Style") + for usage in sorted(usages, key=lambda u: (u.name.lower(), u.weight, u.italic)): + table.add_row(usage.name, self._style_label(usage)) + + @staticmethod + def _style_label(usage: FontUsage) -> Text: + """Return a colored 'Bold', 'Italic', 'Bold, Italic', or 'Regular' label.""" + parts = [name for flag, name in ((usage.is_bold, "Bold"), (usage.italic, "Italic")) if flag] + if not parts: + return Text("Regular", style="dim") + return Text(", ".join(parts), style="bold yellow") + + def action_close(self) -> None: + """Close the screen.""" + self.dismiss(None) diff --git a/src/pyinkr/fonts.py b/src/pyinkr/fonts.py new file mode 100644 index 0000000..eeeabc8 --- /dev/null +++ b/src/pyinkr/fonts.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import re +import tempfile +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + from pymkv import MKVTrack + +SUPPORTED_SUBTITLE_CODECS: frozenset[str] = frozenset({"SubStationAlpha"}) +"""mkvmerge's human-readable codec name, shared by both ASS and SSA.""" + + +def is_subtitle_track(track: MKVTrack) -> bool: + """Return whether `track` is a subtitle track.""" + return (track.track_type or "").lower() == "subtitles" + + +def is_supported_subtitle(track: MKVTrack) -> bool: + """Return whether `track` is a subtitle format this module can parse (ASS/SSA).""" + return (track.track_codec or "") in SUPPORTED_SUBTITLE_CODECS + + +def _iter_subtitle_lines(track: MKVTrack) -> Iterator[str]: + """Yield the lines of an ASS/SSA subtitle track. + + Must be fully consumed by the caller before this generator resumes + past the `with` block below, or the temporary file will already be + gone. + """ + source = Path(track.file_path) + if source.suffix.lower() in (".ass", ".ssa"): + with source.open(encoding="utf-8-sig", errors="ignore") as f: + yield from f + return + + with tempfile.TemporaryDirectory() as tmp_dir: + output = Path(track.extract(tmp_dir, silent=True)) + with output.open(encoding="utf-8-sig", errors="ignore") as f: + yield from f + + +def _normalize_font_name(name: str) -> str: + """Strip the '@' prefix ASS uses for vertical text (e.g. '@Arial' -> 'Arial').""" + name = name.strip() + if name.startswith("@"): + name = name[1:].strip() + return name + + +def _parse_weight(value: str) -> int: + """Parse a \\b tag value into an OS/2-style weight (100-900).""" + try: + val = round(float(value)) + except ValueError: + return 400 + if val == 0: + return 400 + if val == 1: + return 700 + if val < 100: + return 400 + return val + + +def _parse_bool_field(value: str) -> bool: + """Return whether a Style Bold/Italic field ('-1'/'1' vs '0') is true.""" + return value.strip() not in ("", "0") + + +@dataclass(frozen=True, slots=True) +class FontUsage: + """A font name plus the weight/italic state it was used with.""" + + name: str + weight: int + italic: bool + + @property + def is_bold(self) -> bool: + """Return whether this usage is bold.""" + return self.weight >= 700 + + +@dataclass(frozen=True, slots=True) +class _DialogueLine: + """A single Dialogue event: the style it references, and its raw text.""" + + style: str + text: str + + +@dataclass(frozen=True, slots=True) +class _ParsedAss: + """The subset of an ASS/SSA file relevant to font detection.""" + + styles: dict[str, FontUsage] + dialogue_lines: list[_DialogueLine] + has_embedded_fonts: bool + + +@dataclass(frozen=True, slots=True) +class SubtitleFontInfo: + """Fonts a subtitle track needs, and whether it already embeds fonts.""" + + usages: frozenset[FontUsage] + has_embedded_fonts: bool + + +def _parse_ass_minimal(lines: Iterable[str]) -> _ParsedAss: + """Parse the fields needed for font detection out of ASS/SSA source lines.""" + styles: dict[str, FontUsage] = {} + dialogue_lines: list[_DialogueLine] = [] + has_fonts_section = False + + section = "" + + prefix_len = len("dialogue:") + + for raw_line in lines: + if not (line := raw_line.strip()): + continue + + if line.startswith("[") and line.endswith("]"): + section = line.lower() + if section == "[fonts]": + has_fonts_section = True + + if section in ("[fonts]", "[graphics]"): + continue + + prefix = line[:prefix_len].lower() + + if section in ("[v4+ styles]", "[v4 styles]"): + if prefix.startswith("style:"): + parts = line.split(":", 1)[1].split(",", 9) + if len(parts) < 9 or not (name := parts[0].strip()): + continue + styles[name] = FontUsage( + name=_normalize_font_name(parts[1].strip()), + weight=700 if _parse_bool_field(parts[7].strip()) else 400, + italic=_parse_bool_field(parts[8].strip()), + ) + continue + + if section == "[events]": + if prefix.startswith("dialogue:"): + parts = line.split(":", 1)[1].split(",", 9) + if len(parts) < 10: + continue + dialogue_lines.append(_DialogueLine(parts[3].strip(), parts[9])) + + return _ParsedAss(styles, dialogue_lines, has_fonts_section) + + +_PAREN_CONTENT_RE = re.compile(r"\([^)]*\)") + +_TAG_RE = re.compile( + r"\\(?:" + r"fn(?P[^\\]*)" + r"|r(?P[^\\]*)" + r"|i(?!clip)(?P[^\\]*)" + r"|b(?!ord|lur|e)(?P[^\\]*)" + r")" +) + +_OVERRIDE_BLOCK_RE = re.compile(r"\{([^}]*)\}") + + +def _iter_tags(block: str) -> Iterator[tuple[str, str]]: + """Yield (tag, value) for every \\fn/\\r/\\i/\\b override in a {...} block, in order.""" + # Strip parens (\t(...), \pos(...), \clip(...) with huge point lists) first. + # Skip the substitution if there's no '(' -- most blocks don't have one. + if "(" in block: + block = _PAREN_CONTENT_RE.sub("", block) + for m in _TAG_RE.finditer(block): + tag = m.lastgroup + if tag is None: + continue + yield tag, m.group(tag).strip() + + +def _extract_font_usages(styles: dict[str, FontUsage], dialogue_lines: list[_DialogueLine]) -> set[FontUsage]: + """Walk every dialogue line and collect every distinct font/weight/italic combo used. + + Limitation: font changes inside \\t(...) animated transforms aren't tracked. + """ + default_style = styles.get("Default") or next(iter(styles.values()), None) + usages: set[FontUsage] = set() + + for line in dialogue_lines: + line_style = styles.get(line.style, default_style) + if line_style is None: + continue + + current = line_style + usages.add(current) + + if "{" not in line.text: + continue # no override tags to process + + for block in _OVERRIDE_BLOCK_RE.findall(line.text): + for tag, value in _iter_tags(block): + if tag == "r": + current = styles.get(value, line_style) if value else line_style + elif tag == "b" and value: + current = FontUsage(current.name, _parse_weight(value), current.italic) + elif tag == "i" and value: + current = FontUsage(current.name, current.weight, value != "0") + elif tag == "fn": + name = _normalize_font_name(value) if value else line_style.name + current = FontUsage(name, current.weight, current.italic) + usages.add(current) + + return usages + + +@lru_cache(maxsize=32) +def analyze_subtitle_fonts(track: MKVTrack) -> SubtitleFontInfo: + """Return the fonts `track` needs, and whether it already embeds fonts. + + Raises: + ValueError: if `track` isn't a subtitle track, or isn't in a + supported format (ASS/SSA). + """ + if not is_subtitle_track(track): + raise ValueError("Selected track is not a subtitle track.") + + if not is_supported_subtitle(track): + raise ValueError(f"Font checking isn't supported for '{track.track_codec}' subtitles (only ASS/SSA).") + + parsed = _parse_ass_minimal(_iter_subtitle_lines(track)) + usages = _extract_font_usages(parsed.styles, parsed.dialogue_lines) + return SubtitleFontInfo(frozenset(usages), parsed.has_embedded_fonts) diff --git a/src/pyinkr/style.tcss b/src/pyinkr/style.tcss index 818a1db..8e8ba1a 100644 --- a/src/pyinkr/style.tcss +++ b/src/pyinkr/style.tcss @@ -131,6 +131,38 @@ NoticeWidget { color: $primary-lighten-3; } +FontsScreen { + align: center middle; + + Container { + width: 85%; + height: 80%; + + border: $border; + background: $panel; + border-title-color: $text; + border-title-background: $panel; + border-subtitle-color: $text-muted; + border-subtitle-background: $panel; + + #fonts-note { + height: auto; + padding: 0 1; + margin-bottom: 1; + color: $warning; + text-style: italic; + } + + #fonts-table { + height: 1fr; + } + + Center { + height: 1fr; + } + } +} + ProgressBarScreen { align: center middle; diff --git a/src/pyinkr/widgets.py b/src/pyinkr/widgets.py index 0ed3046..782b92e 100644 --- a/src/pyinkr/widgets.py +++ b/src/pyinkr/widgets.py @@ -14,8 +14,9 @@ from textual_fspicker import FileOpen from typing_extensions import override +from pyinkr import fonts from pyinkr.decorators import catch_errors -from pyinkr.dialogs import DelayScreen, EditScreen +from pyinkr.dialogs import DelayScreen, EditScreen, FontsScreen if TYPE_CHECKING: from typing import Callable @@ -39,6 +40,7 @@ class ListTrack(ListView): Binding("l", "edit_lang", "Lang"), Binding("y", "edit_delay", "Delay"), Binding("d", "toggle_default", "Toggle Default"), + Binding("f", "check_fonts", "Fonts"), Binding("enter,space", "select", "Select", show=False), Binding("alt+up", "move_up", "Move Up", show=False), Binding("alt+down", "move_down", "Move Down", show=False), @@ -101,6 +103,21 @@ async def action_edit_delay(self) -> None: track.sync = result or None self.get_checkbox.label = self.formatted_text(track) + @work(exclusive=True, thread=True) + @catch_errors(severity="warning") + async def action_check_fonts(self) -> None: + """Show the fonts required by the selected subtitle track.""" + track = self.get_track + self.app.call_from_thread(setattr, self, "loading", True) + try: + info = fonts.analyze_subtitle_fonts(track) + finally: + self.app.call_from_thread(setattr, self, "loading", False) + self.app.call_from_thread( + self.app.push_screen, + FontsScreen(info, title=f"Fonts — {self._track_label(track)}"), + ) + @staticmethod def _track_label(track: "MKVTrack") -> str: """Build a short human label used to identify a track in dialog titles."""