ocr-foundry 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
khocr_gen/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Application-level error types for khocr-gen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class ErrorContext:
10
+ """Machine-readable context attached to domain-level exceptions."""
11
+
12
+ code: str
13
+ detail: str
14
+
15
+
16
+ class KhocrGenError(Exception):
17
+ """Base application exception."""
18
+
19
+ def __init__(self, message: str, *, code: str = "internal_error") -> None:
20
+ super().__init__(message)
21
+ self.context = ErrorContext(code=code, detail=message)
22
+
23
+
24
+ class InputValidationError(KhocrGenError):
25
+ """Raised when user input is invalid or incomplete."""
26
+
27
+
28
+ class GenerationError(KhocrGenError):
29
+ """Raised when dataset generation fails."""
30
+
31
+
32
+ class FontLoadError(KhocrGenError):
33
+ """Raised when a font file cannot be loaded."""
@@ -0,0 +1,50 @@
1
+ """Offline scoring CLI; deliberately independent of rendering/model dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ import argparse
11
+
12
+ from .evaluation import evaluate_files
13
+
14
+
15
+ def add_args(parser: argparse.ArgumentParser) -> None:
16
+ parser.add_argument(
17
+ "--reference",
18
+ required=True,
19
+ help="Reference UTF-8 JSONL with id/text and optional metadata.language",
20
+ )
21
+ parser.add_argument(
22
+ "--predictions", required=True, help="Prediction UTF-8 JSONL with matching id/text"
23
+ )
24
+ parser.add_argument(
25
+ "--normalization",
26
+ choices=["none", "NFC"],
27
+ default="none",
28
+ help="Always report raw scores; NFC additionally reports Unicode NFC scores",
29
+ )
30
+ parser.add_argument(
31
+ "--word-tokenization",
32
+ choices=["whitespace"],
33
+ default=None,
34
+ help="Opt in to whitespace WER; not linguistic segmentation for unspaced scripts",
35
+ )
36
+
37
+
38
+ def run(args: argparse.Namespace) -> int:
39
+ try:
40
+ report = evaluate_files(
41
+ args.reference,
42
+ args.predictions,
43
+ normalization=args.normalization,
44
+ word_tokenization=args.word_tokenization,
45
+ )
46
+ except (ValueError, OSError) as exc:
47
+ print(f"error: {exc}", file=sys.stderr)
48
+ return 2
49
+ print(json.dumps(report, ensure_ascii=False, allow_nan=False, indent=2))
50
+ return 0
@@ -0,0 +1,259 @@
1
+ """Offline, stdlib-only scoring of explicitly paired OCR JSONL transcripts.
2
+
3
+ See docs/EVALUATION.md for the input schema, metric semantics, and heldout protocol.
4
+ This module neither runs an OCR model nor loads synthetic generation dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import unicodedata
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping, Sequence
17
+ from os import PathLike
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class _Sample:
22
+ text: str
23
+ language: str
24
+
25
+
26
+ def _string(value: object, field: str, context: str, *, nonempty: bool = False) -> str:
27
+ if not isinstance(value, str) or (nonempty and not value.strip()):
28
+ qualifier = "nonempty " if nonempty else ""
29
+ raise ValueError(f"{context}: {field} must be a {qualifier}string")
30
+ try:
31
+ value.encode("utf-8")
32
+ except UnicodeEncodeError as exc:
33
+ raise ValueError(f"{context}: {field} contains an unpaired Unicode surrogate") from exc
34
+ return value
35
+
36
+
37
+ def _invalid_constant(value: str) -> None:
38
+ raise ValueError(f"invalid JSON constant {value}")
39
+
40
+
41
+ def _read_samples(path: str | PathLike[str], *, reference: bool) -> dict[str, _Sample]:
42
+ samples: dict[str, _Sample] = {}
43
+ role = "reference" if reference else "prediction"
44
+ try:
45
+ with Path(path).open(encoding="utf-8") as stream:
46
+ for line_number, line in enumerate(stream, 1):
47
+ context = f"{role} {path}:{line_number}"
48
+ try:
49
+ record = json.loads(line, parse_constant=_invalid_constant)
50
+ except ValueError as exc:
51
+ raise ValueError(f"{context}: invalid JSON: {exc}") from exc
52
+ if not isinstance(record, dict):
53
+ raise ValueError(f"{context}: each record must be a JSON object")
54
+ sample_id = _string(record.get("id"), "id", context, nonempty=True)
55
+ if sample_id in samples:
56
+ raise ValueError(f"{context}: duplicate id {sample_id!r}")
57
+ text = _string(record.get("text"), "text", context)
58
+ language = "und"
59
+ if reference:
60
+ metadata = record.get("metadata", {})
61
+ if not isinstance(metadata, dict):
62
+ raise ValueError(f"{context}: metadata must be a JSON object")
63
+ language = _string(
64
+ metadata.get("language", "und"),
65
+ "metadata.language",
66
+ context,
67
+ nonempty=True,
68
+ )
69
+ samples[sample_id] = _Sample(text, language)
70
+ except UnicodeDecodeError as exc:
71
+ raise ValueError(f"{role} {path}: invalid UTF-8: {exc}") from exc
72
+ if not samples:
73
+ raise ValueError(f"{role} {path}: empty corpus")
74
+ return samples
75
+
76
+
77
+ def _edit_distance(reference: Sequence[str], prediction: Sequence[str]) -> int:
78
+ """Unit-cost Levenshtein distance, with memory linear in the shorter sequence."""
79
+ if len(reference) < len(prediction):
80
+ reference, prediction = prediction, reference
81
+ previous = list(range(len(prediction) + 1))
82
+ for row, expected in enumerate(reference, 1):
83
+ current = [row]
84
+ for column, actual in enumerate(prediction, 1):
85
+ current.append(
86
+ min(
87
+ previous[column] + 1,
88
+ current[column - 1] + 1,
89
+ previous[column - 1] + (expected != actual),
90
+ )
91
+ )
92
+ previous = current
93
+ return previous[-1]
94
+
95
+
96
+ @dataclass
97
+ class _Counts:
98
+ samples: int = 0
99
+ exact_lines: int = 0
100
+ empty_reference_lines: int = 0
101
+ character_edits: int = 0
102
+ reference_characters: int = 0
103
+ word_edits: int = 0
104
+ reference_words: int = 0
105
+
106
+ def metrics(self, *, words: bool) -> dict[str, Any]:
107
+ result = {
108
+ "samples": self.samples,
109
+ "exact_lines": self.exact_lines,
110
+ "line_accuracy": self.exact_lines / self.samples,
111
+ "empty_reference_lines": self.empty_reference_lines,
112
+ "character_edits": self.character_edits,
113
+ "reference_characters": self.reference_characters,
114
+ "cer": (
115
+ self.character_edits / self.reference_characters
116
+ if self.reference_characters
117
+ else None
118
+ ),
119
+ }
120
+ if words:
121
+ result.update(
122
+ word_edits=self.word_edits,
123
+ reference_words=self.reference_words,
124
+ wer=self.word_edits / self.reference_words if self.reference_words else None,
125
+ )
126
+ return result
127
+
128
+
129
+ def _score(
130
+ references: dict[str, _Sample],
131
+ predictions: dict[str, _Sample],
132
+ *,
133
+ normalization: str,
134
+ words: bool,
135
+ ) -> dict[str, Any]:
136
+ overall = _Counts()
137
+ by_language: dict[str, _Counts] = {}
138
+ for sample_id, reference in references.items():
139
+ expected = reference.text
140
+ actual = predictions[sample_id].text
141
+ if normalization == "NFC":
142
+ expected = unicodedata.normalize("NFC", expected)
143
+ actual = unicodedata.normalize("NFC", actual)
144
+ character_edits = _edit_distance(expected, actual)
145
+ expected_words = expected.split() if words else []
146
+ word_edits = _edit_distance(expected_words, actual.split()) if words else 0
147
+ group = by_language.setdefault(reference.language, _Counts())
148
+ for counts in (overall, group):
149
+ counts.samples += 1
150
+ counts.exact_lines += expected == actual
151
+ counts.empty_reference_lines += not expected
152
+ counts.character_edits += character_edits
153
+ counts.reference_characters += len(expected)
154
+ counts.word_edits += word_edits
155
+ counts.reference_words += len(expected_words)
156
+ return {
157
+ "overall": overall.metrics(words=words),
158
+ "by_language": {
159
+ language: counts.metrics(words=words)
160
+ for language, counts in sorted(by_language.items())
161
+ },
162
+ }
163
+
164
+
165
+ def score_records(
166
+ references: Mapping[str, tuple[str, str]],
167
+ predictions: Mapping[str, str],
168
+ *,
169
+ normalization: str = "none",
170
+ word_tokenization: str | None = None,
171
+ ) -> dict[str, Any]:
172
+ """Score in-memory records with the same semantics as ``evaluate_files``.
173
+
174
+ ``references`` maps each ID to ``(text, group label)``; ``predictions`` maps
175
+ each ID to predicted text. The ID sets must match exactly and be nonempty.
176
+ Returns the ``evaluate_files`` report; ``by_language`` is keyed by the supplied
177
+ group labels, so callers may group by any reference attribute.
178
+ """
179
+ _check_options(normalization, word_tokenization)
180
+ if not references:
181
+ raise ValueError("reference records: empty corpus")
182
+ missing = sorted(references.keys() - predictions.keys())
183
+ extra = sorted(predictions.keys() - references.keys())
184
+ if missing or extra:
185
+ raise ValueError(
186
+ f"sample ID mismatch: missing prediction IDs {missing!r}; "
187
+ f"extra prediction IDs {extra!r}"
188
+ )
189
+ return _report(
190
+ {key: _Sample(text, group) for key, (text, group) in references.items()},
191
+ {key: _Sample(text, "und") for key, text in predictions.items()},
192
+ normalization=normalization,
193
+ word_tokenization=word_tokenization,
194
+ )
195
+
196
+
197
+ def _check_options(normalization: str, word_tokenization: str | None) -> None:
198
+ if normalization not in ("none", "NFC"):
199
+ raise ValueError("normalization must be 'none' or 'NFC'")
200
+ if word_tokenization not in (None, "whitespace"):
201
+ raise ValueError("word_tokenization must be None or 'whitespace'")
202
+
203
+
204
+ def _report(
205
+ references: dict[str, _Sample],
206
+ predictions: dict[str, _Sample],
207
+ *,
208
+ normalization: str,
209
+ word_tokenization: str | None,
210
+ ) -> dict[str, Any]:
211
+ words = word_tokenization == "whitespace"
212
+ result: dict[str, Any] = {
213
+ "schema_version": 1,
214
+ "normalization": normalization,
215
+ "word_tokenization": word_tokenization,
216
+ "unicode_version": unicodedata.unidata_version,
217
+ "raw": _score(references, predictions, normalization="none", words=words),
218
+ }
219
+ if normalization == "NFC":
220
+ result["normalized"] = _score(references, predictions, normalization="NFC", words=words)
221
+ return result
222
+
223
+
224
+ def evaluate_files(
225
+ reference_path: str | PathLike[str],
226
+ prediction_path: str | PathLike[str],
227
+ *,
228
+ normalization: str = "none",
229
+ word_tokenization: str | None = None,
230
+ ) -> dict[str, Any]:
231
+ """Score UTF-8 JSONL files with identical explicit string IDs.
232
+
233
+ Records require ``id`` and ``text`` strings. Reference ``metadata.language``
234
+ supplies grouping (default ``und``); prediction metadata never supplies truth.
235
+ ``normalization`` accepts ``none`` or ``NFC``. Raw metrics are always returned;
236
+ NFC additionally returns a separate ``normalized`` report. WER is omitted
237
+ unless ``word_tokenization='whitespace'`` is explicitly selected.
238
+
239
+ CER/WER are corpus edit totals divided by reference totals, or None for a
240
+ zero denominator. Empty reference lines still contribute insertion errors.
241
+ A corpus with no records is an error. No text is stripped or case-folded.
242
+
243
+ Return only JSON-serializable values, without writing files or printing.
244
+ Raise ValueError for invalid options, encoding, records, or ID mismatches;
245
+ raise OSError for file access failures. CLI adapters should catch both.
246
+ """
247
+ _check_options(normalization, word_tokenization)
248
+ references = _read_samples(reference_path, reference=True)
249
+ predictions = _read_samples(prediction_path, reference=False)
250
+ missing = sorted(references.keys() - predictions.keys())
251
+ extra = sorted(predictions.keys() - references.keys())
252
+ if missing or extra:
253
+ raise ValueError(
254
+ f"sample ID mismatch: missing prediction IDs {missing!r}; "
255
+ f"extra prediction IDs {extra!r}"
256
+ )
257
+ return _report(
258
+ references, predictions, normalization=normalization, word_tokenization=word_tokenization
259
+ )
khocr_gen/fonts.py ADDED
@@ -0,0 +1,316 @@
1
+ """Font management and script-aware font selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections import OrderedDict
7
+ from pathlib import Path
8
+ from typing import Any, ClassVar
9
+
10
+ from PIL import ImageFont
11
+
12
+ from .provenance import get_font_info, get_font_sha256
13
+ from .text_support import (
14
+ check_cluster_fallback_coverage,
15
+ find_fallback_fonts_for_clusters,
16
+ get_bidi_levels,
17
+ resolve_bidi_runs,
18
+ split_grapheme_clusters,
19
+ )
20
+
21
+ __all__ = [
22
+ "FontManager",
23
+ "check_cluster_fallback_coverage",
24
+ "find_fallback_fonts_for_clusters",
25
+ "get_bidi_levels",
26
+ "resolve_bidi_runs",
27
+ "split_grapheme_clusters",
28
+ ]
29
+
30
+ try:
31
+ from PIL import Image # noqa: F401
32
+
33
+ HAS_PIL = True
34
+ except ImportError:
35
+ HAS_PIL = False
36
+
37
+
38
+ class FontManager:
39
+ """Load and select fonts for Khmer and English text.
40
+
41
+ Font files are expected under `<fonts_dir>/khmer/` and `<fonts_dir>/english/` subdirectories.
42
+ Fonts placed directly in *fonts_dir* are added to both pools as a fallback.
43
+
44
+ Supported formats: ttf, otf, ttc, woff, woff2.
45
+ """
46
+
47
+ _FONT_EXTENSIONS: ClassVar[set[str]] = {".ttf", ".otf", ".ttc", ".otc", ".woff", ".woff2"}
48
+
49
+ # Sizes each font file is made available at (see `_load_fonts_from`).
50
+ # Registration is metadata-only (path + size, no FreeType face); actual
51
+ # faces are loaded lazily by `get_font_by_path_and_size`.
52
+ _STANDARD_SIZES: ClassVar[list[int]] = [28, 32, 36, 40, 44, 48]
53
+
54
+ # Bound on the font-face cache (see `get_font_by_path_and_size`).
55
+ # Every (font_path, size) pair -- proportional sizing's near-continuous
56
+ # scale as well as the standard sizes above -- is loaded lazily on first
57
+ # use; without a cap, a long run (or `font_mode=all`, which touches every
58
+ # font at every standard size) would pin every FreeType face it has ever
59
+ # seen in memory at once, in every process.
60
+ _DYNAMIC_FONT_CACHE_SIZE: ClassVar[int] = 1024
61
+
62
+ def __init__(
63
+ self,
64
+ language: str = "mixed",
65
+ fonts_dir: str = "fonts",
66
+ verbose: bool = True,
67
+ dynamic_font_cache_size: int = _DYNAMIC_FONT_CACHE_SIZE,
68
+ *,
69
+ multilingual: bool = False,
70
+ font_pool: str | None = None,
71
+ ) -> None:
72
+ if not HAS_PIL:
73
+ raise ImportError("Pillow is required. Install with: pip install Pillow")
74
+
75
+ self.language = language
76
+ self.multilingual = multilingual
77
+ self.font_pool = font_pool
78
+ self.pools: dict[str, list[tuple[str, int, Any]]] = {}
79
+ self.fonts_dir = Path(fonts_dir)
80
+ self.verbose = verbose
81
+ self.khmer_fonts: list[tuple[str, int, Any]] = []
82
+ self.english_fonts: list[tuple[str, int, Any]] = []
83
+ self.all_fonts: list[tuple[str, int, Any]] = []
84
+ self._dynamic_font_cache_size = max(0, dynamic_font_cache_size)
85
+ self._dynamic_font_cache: OrderedDict[tuple[str, int], Any] = OrderedDict()
86
+ self._font_styles: dict[str, set[str]] = {}
87
+ self._text_has_khmer_cache: dict[str, bool] = {}
88
+ self._load_fonts()
89
+ if font_pool is not None and font_pool not in self.pools:
90
+ raise ValueError(
91
+ f"Unknown font pool {font_pool!r}; available: {', '.join(sorted(self.pools)) or '(none)'}"
92
+ )
93
+
94
+ @classmethod
95
+ def _collect_font_files(cls, directory: Path) -> list[Path]:
96
+ """Recursively collect font files under *directory*."""
97
+ files: list[Path] = []
98
+ if not directory.exists():
99
+ return files
100
+ for path in sorted(directory.rglob("*")):
101
+ if path.is_file() and path.suffix.lower() in cls._FONT_EXTENSIONS:
102
+ files.append(path)
103
+ return files
104
+
105
+ def _load_fonts_from(self, directory: Path, target_list: list) -> int:
106
+ """Register every font file under *directory* into *target_list*.
107
+
108
+ Registration only records `(path, size, None)` entries; the actual
109
+ FreeType face is loaded on first use (see `get_font_by_path_and_size`).
110
+ """
111
+ font_paths = self._collect_font_files(directory)
112
+ added = 0
113
+ for font_path in font_paths:
114
+ for size in self._STANDARD_SIZES:
115
+ entry = (str(font_path), size, None)
116
+ target_list.append(entry)
117
+ self.all_fonts.append(entry)
118
+ added += 1
119
+ return added
120
+
121
+ def _load_fonts(self) -> None:
122
+ """Load fonts from khmer/ and english/ subdirectories."""
123
+ if self.verbose:
124
+ print(f"\nLoading fonts from: {self.fonts_dir.absolute()}")
125
+
126
+ if not self.fonts_dir.exists():
127
+ print(f" Warning: Fonts directory not found: {self.fonts_dir}")
128
+ print(" Creating directory structure...")
129
+ (self.fonts_dir / "khmer").mkdir(parents=True, exist_ok=True)
130
+ (self.fonts_dir / "english").mkdir(parents=True, exist_ok=True)
131
+ print("\n No fonts found!")
132
+ print(f" Khmer → {(self.fonts_dir / 'khmer').absolute()}")
133
+ print(f" English → {(self.fonts_dir / 'english').absolute()}")
134
+ return
135
+
136
+ khmer_dir = self.fonts_dir / "khmer"
137
+ english_dir = self.fonts_dir / "english"
138
+
139
+ self.pools = {"khmer": self.khmer_fonts, "english": self.english_fonts}
140
+ for directory in sorted(self.fonts_dir.iterdir()):
141
+ if directory.is_dir():
142
+ pool = self.pools.setdefault(directory.name, [])
143
+ self._load_fonts_from(directory, pool)
144
+
145
+ # Fallback: fonts placed directly in fonts_dir go to both pools
146
+ root_font_paths = [
147
+ p for p in self._collect_font_files(self.fonts_dir) if p.parent == self.fonts_dir
148
+ ]
149
+ n_root = 0
150
+ self.pools["root"] = []
151
+ if root_font_paths:
152
+ for font_path in root_font_paths:
153
+ for size in self._STANDARD_SIZES:
154
+ entry = (str(font_path), size, None)
155
+ self.pools["root"].append(entry)
156
+ self.khmer_fonts.append(entry)
157
+ self.english_fonts.append(entry)
158
+ self.all_fonts.append(entry)
159
+ n_root += 1
160
+
161
+ if not self.all_fonts:
162
+ print("\n No font files found.")
163
+ print(
164
+ f" Please add fonts to:\n Khmer -> {khmer_dir.absolute()}\n English -> {english_dir.absolute()}"
165
+ )
166
+ return
167
+
168
+ if self.verbose:
169
+ print("\n Font Summary:")
170
+ print(f" Total entries : {len(self.all_fonts)} (across all sizes)")
171
+ print(f" Khmer entries : {len(self.khmer_fonts)}")
172
+ print(f" English entries: {len(self.english_fonts)}")
173
+
174
+ def _text_has_khmer(self, text: str) -> bool:
175
+ """Check if *text* contains any Khmer Unicode characters."""
176
+ cached = self._text_has_khmer_cache.get(text)
177
+ if cached is not None:
178
+ return cached
179
+ result = any("ក" <= c <= "៿" for c in text)
180
+ if len(self._text_has_khmer_cache) >= 100_000:
181
+ self._text_has_khmer_cache.clear()
182
+ self._text_has_khmer_cache[text] = result
183
+ return result
184
+
185
+ def _pool_for(self, text: str) -> list:
186
+ """Return the script-appropriate font-entry pool for *text*."""
187
+ if self.font_pool is not None:
188
+ return self.pools[self.font_pool]
189
+ if self.multilingual:
190
+ return self.all_fonts
191
+ if self.language in self.pools and self.language != "mixed":
192
+ return self.pools[self.language]
193
+ has_khmer = self._text_has_khmer(text)
194
+ if has_khmer and self.khmer_fonts:
195
+ return self.khmer_fonts
196
+ if not has_khmer and self.english_fonts:
197
+ return self.english_fonts
198
+ return self.all_fonts
199
+
200
+ @staticmethod
201
+ def _detect_font_style(font_path: str) -> set[str]:
202
+ """Detect bold/italic style tags for a font file from its name + filename."""
203
+ tags: set[str] = set()
204
+ stem = Path(font_path).stem.lower()
205
+ if "bold" in stem:
206
+ tags.add("bold")
207
+ if "italic" in stem or "oblique" in stem:
208
+ tags.add("italic")
209
+ try:
210
+ style_name = ImageFont.truetype(font_path, 28).getname()[1]
211
+ style = style_name.lower() if style_name else ""
212
+ if "bold" in style:
213
+ tags.add("bold")
214
+ if "italic" in style or "oblique" in style:
215
+ tags.add("italic")
216
+ except Exception:
217
+ pass
218
+ return tags
219
+
220
+ def _style_tags(self, font_path: str) -> set[str]:
221
+ """Cached style tags for a font path (see `_detect_font_style`)."""
222
+ tags = self._font_styles.get(font_path)
223
+ if tags is None:
224
+ tags = self._detect_font_style(font_path)
225
+ self._font_styles[font_path] = tags
226
+ return tags
227
+
228
+ def random_font_path_with_style(self, text: str, styles: set[str]) -> str | None:
229
+ """Random font *path* from the script-appropriate pool whose style matches *styles*.
230
+
231
+ - ``{"bold"}`` -> any font tagged bold (a bold-italic font qualifies).
232
+ - ``{"italic"}`` -> any font tagged italic/oblique.
233
+ - ``{"bold", "italic"}`` -> prefer a font tagged both, else a bold font.
234
+ Returns ``None`` when no match exists.
235
+ """
236
+ if not styles:
237
+ return None
238
+ pool = self._pool_for(text)
239
+
240
+ def _paths(required: set[str]) -> list[str]:
241
+ return sorted({entry[0] for entry in pool if required <= self._style_tags(entry[0])})
242
+
243
+ if styles == {"bold", "italic"}:
244
+ both = _paths({"bold", "italic"})
245
+ if both:
246
+ return random.choice(both)
247
+ bold = _paths({"bold"})
248
+ if bold:
249
+ return random.choice(bold)
250
+ return None
251
+
252
+ matches = _paths(styles)
253
+ return random.choice(matches) if matches else None
254
+
255
+ def get_random_font(self, text: str) -> Any:
256
+ """Get a random font appropriate for *text*."""
257
+ pool = self._pool_for(text)
258
+ if not pool:
259
+ return None
260
+ path, size, _ = random.choice(pool)
261
+ return self.get_font_by_path_and_size(path, size)
262
+
263
+ def get_random_font_for_script(self, script: str) -> tuple[Any, int] | tuple[None, None]:
264
+ """Get a random font for a specific script ('khmer' or 'english')."""
265
+ if script == "khmer" and self.khmer_fonts:
266
+ path, size, _ = random.choice(self.khmer_fonts)
267
+ elif script == "english" and self.english_fonts:
268
+ path, size, _ = random.choice(self.english_fonts)
269
+ elif self.all_fonts:
270
+ path, size, _ = random.choice(self.all_fonts)
271
+ else:
272
+ return None, None
273
+ return self.get_font_by_path_and_size(path, size), size
274
+
275
+ def get_font_by_ref(self, font_ref: Any) -> Any:
276
+ """Resolve a `(path, size)` reference back to a font, loading lazily."""
277
+ if (
278
+ isinstance(font_ref, tuple)
279
+ and len(font_ref) >= 2
280
+ and font_ref[0] is not None
281
+ and font_ref[1] is not None
282
+ ):
283
+ return self.get_font_by_path_and_size(str(font_ref[0]), int(font_ref[1]))
284
+ return font_ref
285
+
286
+ def get_font_by_path_and_size(self, font_path: str, size: int) -> Any:
287
+ """Get font by path and size, loading and caching lazily.
288
+
289
+ Every (font_path, size) pair -- including the standard sizes each
290
+ font is registered at by `_load_fonts` -- is loaded on first use and
291
+ served from a bounded LRU cache, so neither a long run nor
292
+ `font_mode=all` (which touches every font at every standard size)
293
+ can pin every FreeType face in memory at once.
294
+ """
295
+ key = (str(font_path), int(size))
296
+ if key in self._dynamic_font_cache:
297
+ self._dynamic_font_cache.move_to_end(key)
298
+ return self._dynamic_font_cache[key]
299
+ try:
300
+ font = ImageFont.truetype(str(font_path), int(size), index=0)
301
+ except Exception:
302
+ return None
303
+ if self._dynamic_font_cache_size > 0:
304
+ self._dynamic_font_cache[key] = font
305
+ self._dynamic_font_cache.move_to_end(key)
306
+ while len(self._dynamic_font_cache) > self._dynamic_font_cache_size:
307
+ self._dynamic_font_cache.popitem(last=False)
308
+ return font
309
+
310
+ def get_font_sha256(self, font_path: Path | str) -> str:
311
+ """Return the process-cached font digest used by execution metadata."""
312
+ return get_font_sha256(font_path)
313
+
314
+ def get_available_fonts(self) -> list[dict[str, Any]]:
315
+ """Inventory registered font binaries once, independently of size entries."""
316
+ return [get_font_info(path) for path in sorted({entry[0] for entry in self.all_fonts})]