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/__init__.py +3 -0
- synthocr/__main__.py +3 -0
- synthocr/cli.py +540 -0
- synthocr/config.py +589 -0
- synthocr/corpora.py +160 -0
- synthocr/corpus_store.py +222 -0
- synthocr/downloads.py +77 -0
- synthocr/experiments.py +399 -0
- synthocr/font_catalog.json +579 -0
- synthocr/font_profiles.json +18 -0
- synthocr/fonts.py +225 -0
- synthocr/home.py +150 -0
- synthocr/kurdish_characters.json +442 -0
- synthocr/kurdish_spelling.json +45 -0
- synthocr/lexicons.json +82 -0
- synthocr/pipeline.py +512 -0
- synthocr/preview.py +112 -0
- synthocr/render.py +555 -0
- synthocr/spelling.py +111 -0
- synthocr/static/app.css +1111 -0
- synthocr/static/app.js +1759 -0
- synthocr/static/brand.svg +5 -0
- synthocr/static/index.html +773 -0
- synthocr/static/theme.js +28 -0
- synthocr/text.py +437 -0
- synthocr/verify.py +307 -0
- synthocr/web.py +2259 -0
- synthocr-2.1.0.dist-info/METADATA +261 -0
- synthocr-2.1.0.dist-info/RECORD +34 -0
- synthocr-2.1.0.dist-info/WHEEL +4 -0
- synthocr-2.1.0.dist-info/entry_points.txt +3 -0
- synthocr-2.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- synthocr-2.1.0.dist-info/licenses/LICENSE-MIT +21 -0
- synthocr-2.1.0.dist-info/licenses/NOTICE +25 -0
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
|
+
}
|