synthocr 2.1.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.
synthocr/fonts.py ADDED
@@ -0,0 +1,225 @@
1
+ """Shared font discovery, bounded coverage caches, and verified font downloads."""
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ from collections import OrderedDict
7
+ from dataclasses import dataclass
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+ from urllib.request import urlopen
11
+
12
+ from fontTools.ttLib import TTCollection, TTFont, TTLibError
13
+ from PIL import ImageFont
14
+
15
+ from .downloads import download, within
16
+ from .text import required_codepoints as required_codepoints
17
+
18
+ LOG = logging.getLogger(__name__)
19
+ FONT_SUFFIXES = {".ttf", ".otf", ".ttc", ".otc"}
20
+ # Pinned open-font downloads, shipped with the package so installed copies can fetch them.
21
+ CATALOG = Path(__file__).with_name("font_catalog.json")
22
+ MAX_FONT_BYTES = 64 * 1024 * 1024
23
+ PROFILES = ("sorani", "kurmanji", "kurdish")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Font:
28
+ path: str
29
+ index: int = 0
30
+
31
+ @property
32
+ def name(self):
33
+ return f"{Path(self.path).name}#{self.index}"
34
+
35
+
36
+ def discover(directories: list[Path]) -> list[Font]:
37
+ result = []
38
+ seen = set()
39
+ for directory in directories:
40
+ for path in sorted(directory.rglob("*")):
41
+ if not path.is_file() or path.suffix.lower() not in FONT_SUFFIXES:
42
+ continue
43
+ if path.stat().st_size > MAX_FONT_BYTES:
44
+ LOG.warning("Skipping font larger than 64 MiB: %s", path)
45
+ continue
46
+ with path.open("rb") as stream:
47
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
48
+ if digest in seen:
49
+ continue
50
+ seen.add(digest)
51
+ try:
52
+ if path.suffix.lower() in {".ttc", ".otc"}:
53
+ with TTCollection(path, lazy=True) as collection:
54
+ faces = len(collection.fonts)
55
+ else:
56
+ faces = 1
57
+ for index in range(faces):
58
+ font = Font(str(path.resolve()), index)
59
+ ImageFont.truetype(font.path, 12, index=index)
60
+ result.append(font)
61
+ except (OSError, ValueError, TTLibError) as error:
62
+ LOG.warning("Skipping unreadable font %s: %s", path, error)
63
+ if not result:
64
+ raise ValueError("No usable TTF/OTF/TTC/OTC fonts found in the configured directories")
65
+ return result
66
+
67
+
68
+ def font_ids(fonts: list[Font]) -> dict[Font, str]:
69
+ """Return each face's stable ID: the file's SHA-256 and the face index."""
70
+ digests = {}
71
+ result = {}
72
+ for font in fonts:
73
+ if font.path not in digests:
74
+ with Path(font.path).open("rb") as stream:
75
+ digests[font.path] = hashlib.file_digest(stream, "sha256").hexdigest()
76
+ result[font] = f"{digests[font.path]}:{font.index}"
77
+ return result
78
+
79
+
80
+ def select(fonts: list[Font], include: list[str], ids: dict[Font, str] | None = None) -> list[Font]:
81
+ """Keep the faces named in include (file name, "name#face" or ID); empty keeps all."""
82
+ if not include:
83
+ return fonts
84
+ wanted = set(include)
85
+ ids = ids or font_ids(fonts)
86
+ chosen = [font for font in fonts if wanted & {ids[font], Path(font.path).name, font.name}]
87
+ if not chosen:
88
+ raise ValueError("None of the chosen fonts are available; choose at least one font")
89
+ return chosen
90
+
91
+
92
+ class Coverage:
93
+ def __init__(self, limit: int = 64):
94
+ self.limit = limit
95
+ self.cache: OrderedDict[Font, bytes] = OrderedDict()
96
+
97
+ def _mask(self, font: Font) -> bytes:
98
+ if font in self.cache:
99
+ self.cache.move_to_end(font)
100
+ return self.cache[font]
101
+ with TTFont(font.path, fontNumber=font.index, lazy=True) as face:
102
+ cmap = face.getBestCmap() or {}
103
+ maximum = min(max(cmap, default=0), 0x10FFFF)
104
+ bits = bytearray((maximum >> 3) + 1)
105
+ for cp, glyph in cmap.items():
106
+ if 0 <= cp <= 0x10FFFF and glyph != ".notdef":
107
+ bits[cp >> 3] |= 1 << (cp & 7)
108
+ value = bytes(bits)
109
+ self.cache[font] = value
110
+ if len(self.cache) > self.limit:
111
+ self.cache.popitem(last=False)
112
+ return value
113
+
114
+ def missing(self, font: Font, needed: frozenset[int]) -> frozenset[int]:
115
+ bits = self._mask(font)
116
+ return frozenset(
117
+ cp for cp in needed if cp >> 3 >= len(bits) or not bits[cp >> 3] & (1 << (cp & 7))
118
+ )
119
+
120
+ def codepoints(self, font: Font) -> frozenset[int]:
121
+ """Expanded inspection view; generation keeps only compact bitmap caches."""
122
+ return frozenset(
123
+ i * 8 + b
124
+ for i, byte in enumerate(self._mask(font))
125
+ if byte
126
+ for b in range(8)
127
+ if byte & (1 << b)
128
+ )
129
+
130
+ def choose(self, fonts: list[Font], text: str, offset: int, variant: int = 0) -> Font | None:
131
+ needed = required_codepoints(text)
132
+ matches = 0
133
+ for step in range(len(fonts)):
134
+ font = fonts[(offset + step) % len(fonts)]
135
+ if not self.missing(font, needed):
136
+ if matches == variant:
137
+ return font
138
+ matches += 1
139
+ return None
140
+
141
+
142
+ def load_characters(path: Path) -> str:
143
+ """Accept the original font-checking JSON mapping/list and plain UTF-8 text."""
144
+ if path.suffix.lower() != ".json":
145
+ characters = set()
146
+ with path.open(encoding="utf-8") as stream:
147
+ while chunk := stream.read(65536):
148
+ characters.update(chunk)
149
+ return "".join(sorted(characters))
150
+ return _characters(json.loads(path.read_text(encoding="utf-8")))
151
+
152
+
153
+ def load_profile(name: str) -> str:
154
+ """Load a bundled reference without a checkout or companion repository."""
155
+ if name not in PROFILES:
156
+ raise ValueError(f"Unknown font profile: {name}")
157
+ if name == "kurdish":
158
+ resource = files("synthocr").joinpath("kurdish_characters.json")
159
+ return _characters(json.loads(resource.read_text(encoding="utf-8")))
160
+ profiles = json.loads(
161
+ files("synthocr").joinpath("font_profiles.json").read_text(encoding="utf-8")
162
+ )
163
+ profile = profiles[name]
164
+ return profile["letters"] + profile["digits"] + profile["punctuation"]
165
+
166
+
167
+ def _characters(data) -> str:
168
+ if isinstance(data, dict):
169
+ values = list(data.values())
170
+ if all(isinstance(v, dict) for v in values):
171
+ return "".join(v["char"] for v in values if "char" in v)
172
+ return "".join(data)
173
+ if isinstance(data, list):
174
+ return "".join(v["char"] if isinstance(v, dict) else v for v in data)
175
+ raise ValueError("Character JSON must contain a mapping or a list")
176
+
177
+
178
+ def check(fonts: list[Font], text: str, cache_size: int = 64) -> dict:
179
+ coverage = Coverage(cache_size)
180
+ needed = required_codepoints(text)
181
+ results = []
182
+ for font in fonts:
183
+ missing = sorted(coverage.missing(font, needed))
184
+ results.append(
185
+ {
186
+ "font": font.name,
187
+ "path": font.path,
188
+ "face_index": font.index,
189
+ "supported": len(needed) - len(missing),
190
+ "required": len(needed),
191
+ "missing": [f"U+{cp:04X}" for cp in missing],
192
+ "missing_text": "".join(map(chr, missing)),
193
+ }
194
+ )
195
+ return {
196
+ "fonts": len(fonts),
197
+ "characters": len(needed),
198
+ "fully_supported": sum(not row["missing"] for row in results),
199
+ "results": results,
200
+ }
201
+
202
+
203
+ def _download(url: str, digest: str, target: Path, max_bytes: int):
204
+ return download(url, digest, target, max_bytes, {"raw.githubusercontent.com"}, opener=urlopen)
205
+
206
+
207
+ def fetch(catalog: Path, destination: Path, families: list[str] | None = None) -> dict:
208
+ entries = json.loads(catalog.read_text(encoding="utf-8"))["fonts"]
209
+ available = {entry["family"] for entry in entries}
210
+ if families and set(families) - available:
211
+ raise ValueError(f"Unknown families: {sorted(set(families) - available)}")
212
+ installed = []
213
+ for entry in entries:
214
+ if families and entry["family"] not in families:
215
+ continue
216
+ for key in ("file", "license_file"):
217
+ value = entry[key]
218
+ _download(
219
+ value["url"],
220
+ value["sha256"],
221
+ within(destination, value["name"]),
222
+ MAX_FONT_BYTES if key == "file" else 512 * 1024,
223
+ )
224
+ installed.append(entry["family"])
225
+ return {"families": installed, "destination": str(destination)}
synthocr/home.py ADDED
@@ -0,0 +1,150 @@
1
+ """A personal workspace for installed copies that run outside a project folder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ from datetime import date
9
+ from pathlib import Path
10
+
11
+ from .config import Config, load_config
12
+ from .corpora import snapshot
13
+ from .fonts import CATALOG, fetch
14
+ from .text import generate_text, prepare
15
+
16
+ LOG = logging.getLogger(__name__)
17
+
18
+ # Open fonts that draw Sorani, Kurmanji, Arabic or English completely (about 9 MiB).
19
+ SETUP_FONTS = (
20
+ "notosans",
21
+ "notoserif",
22
+ "notonaskharabic",
23
+ "notosansarabic",
24
+ "notonastaliqurdu",
25
+ "amiri",
26
+ "harmattan",
27
+ "ibmplexsansarabic",
28
+ "lateef",
29
+ "scheherazadenew",
30
+ "reemkufi",
31
+ "cairo",
32
+ "markazitext",
33
+ "readexpro",
34
+ "almarai",
35
+ "tajawal",
36
+ )
37
+ # Tatoeba codes for the app's four languages, and the offline template languages.
38
+ SETUP_TATOEBA = ("ckb", "kmr", "ara", "eng")
39
+ SETUP_TEMPLATES = ("ckb", "ar", "en")
40
+ CONFIG = """\
41
+ # Created by `synthocr setup`. Paths are relative to this file.
42
+ [data]
43
+ raw = "data/raw"
44
+ processed = "data/processed"
45
+
46
+ [fonts]
47
+ directories = ["fonts"]
48
+
49
+ [generation]
50
+ output = "dataset/default"
51
+ """
52
+
53
+
54
+ class NotSetUp(ValueError):
55
+ """No config.toml here and no personal workspace yet."""
56
+
57
+
58
+ def home() -> Path:
59
+ """The workspace folder: SYNTHOCR_HOME, or the platform's usual data folder."""
60
+ if value := os.environ.get("SYNTHOCR_HOME"):
61
+ return Path(value).expanduser().resolve()
62
+ if sys.platform == "win32":
63
+ base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
64
+ return base / "SynthOCR"
65
+ if sys.platform == "darwin":
66
+ return Path.home() / "Library" / "Application Support" / "SynthOCR"
67
+ base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
68
+ return base / "synthocr"
69
+
70
+
71
+ def resolve_config(path: Path | None, loader=load_config) -> tuple[Config, bool]:
72
+ """Load an explicit or local config.toml; otherwise the personal workspace's.
73
+
74
+ The second value says whether the personal workspace is used.
75
+ """
76
+ if path is not None:
77
+ return loader(path), False
78
+ if Path("config.toml").is_file():
79
+ return loader(Path("config.toml")), False
80
+ workspace = home() / "config.toml"
81
+ if not workspace.is_file():
82
+ raise NotSetUp("SynthOCR is not set up yet. Run: synthocr setup")
83
+ return loader(workspace), True
84
+
85
+
86
+ def ready(config: Config) -> bool:
87
+ return any(config.path("fonts").rglob("OFL.txt")) and any(
88
+ config.path(config.data.raw).rglob("*.jsonl")
89
+ )
90
+
91
+
92
+ def setup(
93
+ root: Path | None = None,
94
+ *,
95
+ all_fonts: bool = False,
96
+ limit: int = 20_000,
97
+ tatoeba: bool = True,
98
+ ) -> dict:
99
+ """Create the workspace, fetch open fonts and text, and prepare the text index.
100
+
101
+ Safe to run again: downloads are checked against pinned checksums and kept.
102
+ """
103
+ root = (root or home()).resolve()
104
+ root.mkdir(parents=True, exist_ok=True)
105
+ config_path = root / "config.toml"
106
+ if not config_path.exists():
107
+ config_path.write_text(CONFIG, encoding="utf-8")
108
+ config = load_config(config_path)
109
+ LOG.info("Workspace: %s", root)
110
+
111
+ LOG.info("Downloading open fonts…")
112
+ fonts = fetch(CATALOG, config.path("fonts"), None if all_fonts else list(SETUP_FONTS))
113
+
114
+ raw = config.path(config.data.raw)
115
+ templates = []
116
+ for language in SETUP_TEMPLATES:
117
+ output = raw / "synthetic" / f"{language}.jsonl"
118
+ if not output.exists():
119
+ LOG.info("Writing offline %s text templates…", language)
120
+ generate_text(output, limit, 42, "mixed", language)
121
+ templates.append(language)
122
+
123
+ sentences, skipped = 0, None
124
+ # One snapshot is enough; running setup again on another day adds no copies.
125
+ if tatoeba and not (raw / "tatoeba").exists():
126
+ LOG.info("Downloading Tatoeba sentences (CC BY 2.0 France)…")
127
+ try:
128
+ sentences = snapshot(
129
+ raw,
130
+ root / "data" / "corpora.json",
131
+ root / ".cache" / "corpora",
132
+ list(SETUP_TATOEBA),
133
+ date.today().isoformat(),
134
+ limit,
135
+ )["records"]
136
+ except (OSError, ValueError) as error:
137
+ # Templates and the bundled spelling cases still work offline.
138
+ skipped = str(error)
139
+ LOG.warning("Tatoeba download skipped: %s", error)
140
+
141
+ LOG.info("Preparing the text index…")
142
+ prepared = prepare(config)
143
+ return {
144
+ "workspace": str(root),
145
+ "fonts": fonts["families"],
146
+ "templates": templates,
147
+ "tatoeba_sentences": sentences,
148
+ "tatoeba_skipped": skipped,
149
+ "texts": prepared["records"],
150
+ }