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/__init__.py +11 -0
- khocr_gen/_pil_compat.py +25 -0
- khocr_gen/_rust_accel.py +204 -0
- khocr_gen/augmentation.py +1193 -0
- khocr_gen/benchmark.py +888 -0
- khocr_gen/benchmark_cmd.py +103 -0
- khocr_gen/cli.py +246 -0
- khocr_gen/combine.py +214 -0
- khocr_gen/combine_cmd.py +125 -0
- khocr_gen/config.py +1474 -0
- khocr_gen/config_loader.py +157 -0
- khocr_gen/corpus.py +621 -0
- khocr_gen/data_generator.py +1160 -0
- khocr_gen/errors.py +33 -0
- khocr_gen/evaluate_cmd.py +50 -0
- khocr_gen/evaluation.py +259 -0
- khocr_gen/fonts.py +316 -0
- khocr_gen/generate.py +227 -0
- khocr_gen/layout.py +1028 -0
- khocr_gen/layout_cmd.py +371 -0
- khocr_gen/layout_degradation.py +128 -0
- khocr_gen/layout_mesh.py +200 -0
- khocr_gen/layout_shaping.py +264 -0
- khocr_gen/layout_transform.py +238 -0
- khocr_gen/layout_warp.py +473 -0
- khocr_gen/line_height.py +127 -0
- khocr_gen/lmdb_pack.py +132 -0
- khocr_gen/logging.py +25 -0
- khocr_gen/normalizer.py +221 -0
- khocr_gen/parallel.py +366 -0
- khocr_gen/provenance.py +328 -0
- khocr_gen/rendering.py +1607 -0
- khocr_gen/text_support.py +429 -0
- khocr_gen/verify.py +471 -0
- khocr_gen/viewer.py +172 -0
- ocr_foundry-0.2.0.dist-info/METADATA +174 -0
- ocr_foundry-0.2.0.dist-info/RECORD +40 -0
- ocr_foundry-0.2.0.dist-info/WHEEL +4 -0
- ocr_foundry-0.2.0.dist-info/entry_points.txt +3 -0
- ocr_foundry-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
khocr_gen/evaluation.py
ADDED
|
@@ -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})]
|