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/static/theme.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Runs before CSS so a saved preference is applied before the first paint.
|
|
4
|
+
const themePreference = (() => {
|
|
5
|
+
const key = "synthocr.theme";
|
|
6
|
+
function apply(value) {
|
|
7
|
+
const theme = value === "light" ? "light" : "dark";
|
|
8
|
+
document.documentElement.dataset.theme = theme;
|
|
9
|
+
document
|
|
10
|
+
.querySelector('meta[name="theme-color"]')
|
|
11
|
+
?.setAttribute("content", theme === "dark" ? "#101116" : "#f5f5fa");
|
|
12
|
+
return theme;
|
|
13
|
+
}
|
|
14
|
+
let saved = null;
|
|
15
|
+
try {
|
|
16
|
+
saved = localStorage.getItem(key);
|
|
17
|
+
} catch {}
|
|
18
|
+
apply(saved);
|
|
19
|
+
return {
|
|
20
|
+
set(value) {
|
|
21
|
+
const theme = apply(value);
|
|
22
|
+
try {
|
|
23
|
+
localStorage.setItem(key, theme);
|
|
24
|
+
} catch {}
|
|
25
|
+
return theme;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
})();
|
synthocr/text.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Streaming Unicode preparation; SQLite handles indexing and deduplication on disk."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import sqlite3
|
|
9
|
+
import tempfile
|
|
10
|
+
import unicodedata
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import regex
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .corpus_store import (
|
|
18
|
+
add_origin,
|
|
19
|
+
canonical_language,
|
|
20
|
+
corpus_registry,
|
|
21
|
+
create_origins,
|
|
22
|
+
materialize_primary_origin,
|
|
23
|
+
registered_source_languages,
|
|
24
|
+
resolve_source_language,
|
|
25
|
+
)
|
|
26
|
+
from .downloads import sha256
|
|
27
|
+
|
|
28
|
+
GRAPHEME = regex.compile(r"\X")
|
|
29
|
+
SPACE = regex.compile(r"\s+")
|
|
30
|
+
IGNORABLE = regex.compile(r"\A\p{Default_Ignorable_Code_Point}\Z")
|
|
31
|
+
# Preserve ZWJ, ZWNJ, variation selectors and bidi controls: these affect shaping.
|
|
32
|
+
UNWANTED = dict.fromkeys(
|
|
33
|
+
[0xFEFF, *range(0, 9), *range(14, 32), *range(0x7F, 0x85), *range(0x86, 0xA0)]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def required_codepoints(text: str) -> frozenset[int]:
|
|
38
|
+
# Cf includes visible signs such as U+0600 and U+06DD. Ignore only Unicode's
|
|
39
|
+
# actual default-ignorable characters, not the entire format category.
|
|
40
|
+
return frozenset(
|
|
41
|
+
ord(c)
|
|
42
|
+
for c in text
|
|
43
|
+
if not c.isspace()
|
|
44
|
+
and unicodedata.category(c) not in {"Cc", "Cs"}
|
|
45
|
+
and not IGNORABLE.fullmatch(c)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize(text: str, form: str = "NFC") -> str:
|
|
50
|
+
text = text.translate(UNWANTED)
|
|
51
|
+
if form != "none":
|
|
52
|
+
text = unicodedata.normalize(form, text)
|
|
53
|
+
return SPACE.sub(" ", text).strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def chunks(text: str, limit: int):
|
|
57
|
+
"""Wrap at whitespace when possible, with grapheme-safe hard wrapping."""
|
|
58
|
+
current: list[str] = []
|
|
59
|
+
last_space = -1
|
|
60
|
+
for match in GRAPHEME.finditer(text):
|
|
61
|
+
cluster = match.group()
|
|
62
|
+
if len(current) == limit:
|
|
63
|
+
cut = last_space if last_space > 0 else limit
|
|
64
|
+
result = "".join(current[:cut]).strip()
|
|
65
|
+
if result:
|
|
66
|
+
yield result
|
|
67
|
+
current = current[cut + (cut < limit) :]
|
|
68
|
+
last_space = max((i for i, c in enumerate(current) if c.isspace()), default=-1)
|
|
69
|
+
if not current and cluster.isspace():
|
|
70
|
+
continue
|
|
71
|
+
if cluster.isspace():
|
|
72
|
+
last_space = len(current)
|
|
73
|
+
current.append(cluster)
|
|
74
|
+
result = "".join(current).strip()
|
|
75
|
+
if result:
|
|
76
|
+
yield result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _bounded_lines(path: Path, limit: int):
|
|
80
|
+
with path.open(encoding="utf-8-sig", errors="strict") as stream:
|
|
81
|
+
line_no = 0
|
|
82
|
+
while line := stream.readline(limit + 1):
|
|
83
|
+
line_no += 1
|
|
84
|
+
if len(line) > limit:
|
|
85
|
+
raise ValueError(f"{path}:{line_no}: line exceeds max_line_chars={limit}")
|
|
86
|
+
yield line
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def records(path: Path, raw: Path, limit: int):
|
|
90
|
+
lines = _bounded_lines(path, limit)
|
|
91
|
+
if path.suffix.lower() == ".csv":
|
|
92
|
+
reader = csv.DictReader(lines)
|
|
93
|
+
if not reader.fieldnames or "text" not in reader.fieldnames:
|
|
94
|
+
raise ValueError(f"{path}: CSV requires a text column")
|
|
95
|
+
if len(reader.fieldnames) != len(set(reader.fieldnames)):
|
|
96
|
+
raise ValueError(f"{path}: CSV headers must be unique")
|
|
97
|
+
old_limit = csv.field_size_limit(limit)
|
|
98
|
+
try:
|
|
99
|
+
for row in reader:
|
|
100
|
+
if None in row:
|
|
101
|
+
raise ValueError(f"{path}:{reader.line_num}: unexpected CSV columns")
|
|
102
|
+
# DictReader skips blank rows; count embedded newlines backwards
|
|
103
|
+
# from the physical end of this record for an accurate start.
|
|
104
|
+
start_line = reader.line_num - sum(v.count("\n") for v in row.values() if v)
|
|
105
|
+
yield start_line, row
|
|
106
|
+
except csv.Error as error:
|
|
107
|
+
raise ValueError(f"{path}:{reader.line_num}: invalid CSV: {error}") from error
|
|
108
|
+
finally:
|
|
109
|
+
csv.field_size_limit(old_limit)
|
|
110
|
+
else:
|
|
111
|
+
for number, line in enumerate(lines, 1):
|
|
112
|
+
if path.suffix.lower() == ".jsonl":
|
|
113
|
+
if not line.strip():
|
|
114
|
+
continue
|
|
115
|
+
row = json.loads(line)
|
|
116
|
+
if not isinstance(row, dict) or not isinstance(row.get("text"), str):
|
|
117
|
+
raise ValueError(f"{path}:{number}: expected an object with a text string")
|
|
118
|
+
else:
|
|
119
|
+
row = {"text": line}
|
|
120
|
+
yield number, row
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def prepare(config: Config, force: bool = False) -> dict:
|
|
124
|
+
config.validate()
|
|
125
|
+
raw = config.path(config.data.raw)
|
|
126
|
+
processed = config.path(config.data.processed)
|
|
127
|
+
files = sorted(p for p in raw.rglob("*") if p.suffix.lower() in {".txt", ".jsonl", ".csv"})
|
|
128
|
+
if not files:
|
|
129
|
+
raise ValueError(f"No TXT, JSONL or CSV sources found in {raw}")
|
|
130
|
+
sources = [{"path": p.relative_to(raw).as_posix(), "sha256": sha256(p)} for p in files]
|
|
131
|
+
registered_languages = registered_source_languages(raw, sources)
|
|
132
|
+
unknown = config.data.source_languages.keys() - {source["path"] for source in sources}
|
|
133
|
+
if unknown:
|
|
134
|
+
raise ValueError(f"source_languages refers to missing raw sources: {sorted(unknown)}")
|
|
135
|
+
fingerprint = hashlib.sha256(
|
|
136
|
+
json.dumps(
|
|
137
|
+
{
|
|
138
|
+
"schema": 6,
|
|
139
|
+
"settings": asdict(config.data),
|
|
140
|
+
"sources": sources,
|
|
141
|
+
"registered_languages": registered_languages,
|
|
142
|
+
},
|
|
143
|
+
sort_keys=True,
|
|
144
|
+
).encode()
|
|
145
|
+
).hexdigest()
|
|
146
|
+
artifact_names = ["texts.sqlite3", "corpora.json"]
|
|
147
|
+
if config.data.write_jsonl:
|
|
148
|
+
artifact_names.append("texts.jsonl")
|
|
149
|
+
stats_path = processed / "stats.json"
|
|
150
|
+
if not force and all((processed / p).is_file() for p in [*artifact_names, "stats.json"]):
|
|
151
|
+
try:
|
|
152
|
+
stats = json.loads(stats_path.read_text(encoding="utf-8"))
|
|
153
|
+
if (
|
|
154
|
+
isinstance(stats, dict)
|
|
155
|
+
and stats.get("fingerprint") == fingerprint
|
|
156
|
+
and isinstance(stats.get("artifacts"), dict)
|
|
157
|
+
and set(stats["artifacts"]) == set(artifact_names)
|
|
158
|
+
and all(
|
|
159
|
+
sha256(processed / name) == digest
|
|
160
|
+
for name, digest in stats["artifacts"].items()
|
|
161
|
+
)
|
|
162
|
+
):
|
|
163
|
+
return stats | {"cached": True}
|
|
164
|
+
except ValueError, OSError:
|
|
165
|
+
pass # Rebuild corrupt or truncated derived data from unchanged raw sources.
|
|
166
|
+
processed.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
stats = {
|
|
168
|
+
"schema_version": 3,
|
|
169
|
+
"fingerprint": fingerprint,
|
|
170
|
+
"sources": sources,
|
|
171
|
+
"records": 0,
|
|
172
|
+
"duplicates": 0,
|
|
173
|
+
"empty": 0,
|
|
174
|
+
"raw_lines": 0,
|
|
175
|
+
}
|
|
176
|
+
with tempfile.TemporaryDirectory(prefix=".prepare-", dir=processed) as temporary:
|
|
177
|
+
temp = Path(temporary)
|
|
178
|
+
db = sqlite3.connect(temp / "texts.sqlite3")
|
|
179
|
+
try:
|
|
180
|
+
db.execute("PRAGMA cache_size=-4096")
|
|
181
|
+
db.execute("PRAGMA temp_store=FILE")
|
|
182
|
+
db.execute(
|
|
183
|
+
"CREATE TABLE texts (id INTEGER PRIMARY KEY, text TEXT NOT NULL, "
|
|
184
|
+
"source TEXT, line INTEGER, genre TEXT, language TEXT, attribution TEXT)"
|
|
185
|
+
)
|
|
186
|
+
db.execute("CREATE TABLE metadata (fingerprint TEXT NOT NULL)")
|
|
187
|
+
db.execute("INSERT INTO metadata VALUES(?)", (fingerprint,))
|
|
188
|
+
create_origins(db)
|
|
189
|
+
if config.data.deduplicate:
|
|
190
|
+
db.execute("CREATE UNIQUE INDEX unique_text ON texts(language,text)")
|
|
191
|
+
for path in files:
|
|
192
|
+
source = path.relative_to(raw).as_posix()
|
|
193
|
+
for line, row in records(path, raw, config.data.max_line_chars):
|
|
194
|
+
stats["raw_lines"] += 1
|
|
195
|
+
value = row.get("text")
|
|
196
|
+
if not isinstance(value, str):
|
|
197
|
+
raise ValueError(f"{source}:{line}: text must be a string")
|
|
198
|
+
if any(unicodedata.category(c) == "Cs" for c in value):
|
|
199
|
+
raise ValueError(f"{source}:{line}: text contains an unpaired surrogate")
|
|
200
|
+
text = normalize(value, config.data.normalization)
|
|
201
|
+
if not required_codepoints(text):
|
|
202
|
+
stats["empty"] += 1
|
|
203
|
+
continue
|
|
204
|
+
genre = row.get("genre") or path.stem
|
|
205
|
+
source_language = row.get("language")
|
|
206
|
+
if source_language is not None and not isinstance(source_language, str):
|
|
207
|
+
raise ValueError(f"{source}:{line}: language must be a string")
|
|
208
|
+
source_language = source_language or config.data.source_languages.get(
|
|
209
|
+
source, ""
|
|
210
|
+
)
|
|
211
|
+
if not isinstance(genre, str):
|
|
212
|
+
raise ValueError(f"{source}:{line}: genre and language must be strings")
|
|
213
|
+
attribution = row.get("attribution", {})
|
|
214
|
+
if not isinstance(attribution, dict):
|
|
215
|
+
raise ValueError(f"{source}:{line}: attribution must be an object")
|
|
216
|
+
declared_language = source_language
|
|
217
|
+
source_language = resolve_source_language(
|
|
218
|
+
declared_language, attribution, registered_languages.get(source, "")
|
|
219
|
+
)
|
|
220
|
+
language = canonical_language(source_language)
|
|
221
|
+
for chunk in chunks(text, config.data.max_chars):
|
|
222
|
+
if not required_codepoints(chunk):
|
|
223
|
+
stats["empty"] += 1
|
|
224
|
+
continue
|
|
225
|
+
provenance = attribution.copy()
|
|
226
|
+
if source_language != declared_language:
|
|
227
|
+
provenance.setdefault("original_language", declared_language)
|
|
228
|
+
provenance.setdefault("source_language", source_language)
|
|
229
|
+
if language != source_language:
|
|
230
|
+
provenance.setdefault("source_language", source_language)
|
|
231
|
+
if provenance:
|
|
232
|
+
provenance["modified"] = bool(
|
|
233
|
+
provenance.get("modified") or chunk != value
|
|
234
|
+
)
|
|
235
|
+
encoded = json.dumps(provenance, ensure_ascii=False)
|
|
236
|
+
cursor = db.execute(
|
|
237
|
+
"INSERT OR IGNORE INTO texts"
|
|
238
|
+
"(text,source,line,genre,language,attribution) VALUES(?,?,?,?,?,?)",
|
|
239
|
+
(chunk, source, line, genre, language, encoded),
|
|
240
|
+
)
|
|
241
|
+
if cursor.rowcount:
|
|
242
|
+
text_id = cursor.lastrowid
|
|
243
|
+
stats["records"] += 1
|
|
244
|
+
else:
|
|
245
|
+
stats["duplicates"] += 1
|
|
246
|
+
text_id, primary_genre = db.execute(
|
|
247
|
+
"SELECT id,genre FROM texts WHERE language=? AND text=?",
|
|
248
|
+
(language, chunk),
|
|
249
|
+
).fetchone()
|
|
250
|
+
# Diagnostic probes must not replace natural training text.
|
|
251
|
+
if primary_genre == "spelling" and genre != "spelling":
|
|
252
|
+
materialize_primary_origin(db, text_id)
|
|
253
|
+
db.execute(
|
|
254
|
+
"UPDATE texts SET source=?,line=?,genre=?,attribution=? "
|
|
255
|
+
"WHERE id=?",
|
|
256
|
+
(source, line, genre, encoded, text_id),
|
|
257
|
+
)
|
|
258
|
+
add_origin(db, text_id, source, line, genre, source_language, provenance)
|
|
259
|
+
if stats["raw_lines"] % 10000 == 0:
|
|
260
|
+
db.commit()
|
|
261
|
+
db.commit()
|
|
262
|
+
if not stats["records"]:
|
|
263
|
+
raise ValueError("No nonempty text remains after preparation")
|
|
264
|
+
stats["genres"] = dict(db.execute("SELECT genre,COUNT(*) FROM texts GROUP BY genre"))
|
|
265
|
+
stats["languages"] = {
|
|
266
|
+
language or "unspecified": count
|
|
267
|
+
for language, count in db.execute(
|
|
268
|
+
"SELECT language,COUNT(*) FROM texts GROUP BY language"
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
stats["origins"] = db.execute("SELECT COUNT(*) FROM origins").fetchone()[0]
|
|
272
|
+
registry = corpus_registry(db, fingerprint, sources)
|
|
273
|
+
stats["corpora"] = registry["corpora"]
|
|
274
|
+
(temp / "corpora.json").write_text(
|
|
275
|
+
json.dumps(registry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
276
|
+
)
|
|
277
|
+
if config.data.write_jsonl:
|
|
278
|
+
with (temp / "texts.jsonl").open("w", encoding="utf-8") as output:
|
|
279
|
+
for identifier, text, source, line, genre, language, attribution in db.execute(
|
|
280
|
+
"SELECT id,text,source,line,genre,language,attribution FROM texts "
|
|
281
|
+
"ORDER BY id"
|
|
282
|
+
):
|
|
283
|
+
output.write(
|
|
284
|
+
json.dumps(
|
|
285
|
+
{
|
|
286
|
+
"id": identifier,
|
|
287
|
+
"text": text,
|
|
288
|
+
"source": source,
|
|
289
|
+
"line": line,
|
|
290
|
+
"genre": genre,
|
|
291
|
+
"language": language,
|
|
292
|
+
"attribution": json.loads(attribution),
|
|
293
|
+
},
|
|
294
|
+
ensure_ascii=False,
|
|
295
|
+
)
|
|
296
|
+
+ "\n"
|
|
297
|
+
)
|
|
298
|
+
finally:
|
|
299
|
+
db.close()
|
|
300
|
+
for path, source_info in zip(files, sources, strict=True):
|
|
301
|
+
if sha256(path) != source_info["sha256"]:
|
|
302
|
+
raise ValueError(f"Raw source changed during preparation: {path}")
|
|
303
|
+
stats["artifacts"] = {name: sha256(temp / name) for name in artifact_names}
|
|
304
|
+
(temp / "stats.json").write_text(
|
|
305
|
+
json.dumps(stats, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
306
|
+
)
|
|
307
|
+
for name in [*artifact_names, "stats.json"]:
|
|
308
|
+
os.replace(temp / name, processed / name)
|
|
309
|
+
if not config.data.write_jsonl:
|
|
310
|
+
(processed / "texts.jsonl").unlink(missing_ok=True)
|
|
311
|
+
return stats
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
GENRES = (
|
|
315
|
+
"prose",
|
|
316
|
+
"receipt",
|
|
317
|
+
"date",
|
|
318
|
+
"identifier",
|
|
319
|
+
"web",
|
|
320
|
+
"number",
|
|
321
|
+
"address",
|
|
322
|
+
"measurement",
|
|
323
|
+
"arithmetic",
|
|
324
|
+
"contact",
|
|
325
|
+
"table",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def generate_text(output: Path, count: int, seed: int, genre: str, language: str) -> dict:
|
|
330
|
+
"""Write diverse offline OCR examples atomically, with synthetic provenance."""
|
|
331
|
+
from datetime import date, timedelta
|
|
332
|
+
from importlib.resources import files
|
|
333
|
+
|
|
334
|
+
lexicons = json.loads(files("synthocr").joinpath("lexicons.json").read_text(encoding="utf-8"))
|
|
335
|
+
if count <= 0 or genre not in (*GENRES, "mixed") or language not in (*lexicons, "mixed"):
|
|
336
|
+
raise ValueError("Choose a positive count, a supported genre, and a supported language")
|
|
337
|
+
rng = random.Random(seed)
|
|
338
|
+
languages = list(lexicons) if language == "mixed" else [language]
|
|
339
|
+
rng.shuffle(languages)
|
|
340
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
if output.exists():
|
|
342
|
+
raise FileExistsError(output)
|
|
343
|
+
temporary = None
|
|
344
|
+
|
|
345
|
+
def money(cents):
|
|
346
|
+
return f"{cents // 100:,}.{cents % 100:02}"
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
with tempfile.NamedTemporaryFile(
|
|
350
|
+
mode="w", encoding="utf-8", dir=output.parent, delete=False
|
|
351
|
+
) as stream:
|
|
352
|
+
temporary = Path(stream.name)
|
|
353
|
+
for index in range(count):
|
|
354
|
+
locale = languages[index % len(languages)]
|
|
355
|
+
words = lexicons[locale]
|
|
356
|
+
kind = rng.choice(GENRES) if genre == "mixed" else genre
|
|
357
|
+
number = rng.randrange(1, 10_000_000)
|
|
358
|
+
quantity = rng.randrange(1, 100)
|
|
359
|
+
unit = rng.randrange(1, 1_000_000)
|
|
360
|
+
item = rng.choice(words["items"])
|
|
361
|
+
if kind == "prose":
|
|
362
|
+
text = words["prose"].format(
|
|
363
|
+
subject=rng.choice(words["subjects"]),
|
|
364
|
+
verb=rng.choice(words["verbs"]),
|
|
365
|
+
place=rng.choice(words["places"]),
|
|
366
|
+
)
|
|
367
|
+
elif kind == "receipt":
|
|
368
|
+
text = (
|
|
369
|
+
f"{item} | {words['quantity']}: {quantity} | "
|
|
370
|
+
f"{words['unit']}: {money(unit)} | "
|
|
371
|
+
f"{words['total']}: {money(unit * quantity)}"
|
|
372
|
+
)
|
|
373
|
+
elif kind == "table":
|
|
374
|
+
text = (
|
|
375
|
+
f"{number:07} | {item} | {quantity} | "
|
|
376
|
+
f"{money(unit)} | {money(unit * quantity)}"
|
|
377
|
+
)
|
|
378
|
+
elif kind == "date":
|
|
379
|
+
day = date(1990, 1, 1) + timedelta(days=rng.randrange(20000))
|
|
380
|
+
text = (
|
|
381
|
+
f"{day.isoformat()} {rng.randrange(24):02}:"
|
|
382
|
+
f"{rng.randrange(60):02}:{rng.randrange(60):02} UTC+03:00"
|
|
383
|
+
)
|
|
384
|
+
elif kind == "identifier":
|
|
385
|
+
prefix = rng.choice(("INV", "ORD", "REF", "DOC"))
|
|
386
|
+
text = f"{words['invoice']} {prefix}-{number:07}-{rng.randrange(10000):04}"
|
|
387
|
+
elif kind == "web":
|
|
388
|
+
section = rng.choice(("books", "orders", "items", "search"))
|
|
389
|
+
text = f"https://example.org/{section}/{number}?lang={locale}&page={quantity}"
|
|
390
|
+
elif kind == "number":
|
|
391
|
+
text = (
|
|
392
|
+
f"{rng.choice(('', '-', '+'))}{number:,}.{rng.randrange(10000):04} "
|
|
393
|
+
f"{rng.choice(('%', 'USD', 'EUR', 'IQD'))}"
|
|
394
|
+
)
|
|
395
|
+
elif kind == "address":
|
|
396
|
+
text = (
|
|
397
|
+
f"{rng.choice(words['streets'])} {rng.randrange(1, 1000)}, "
|
|
398
|
+
f"{rng.choice(words['cities'])} {rng.randrange(10000, 100000)}"
|
|
399
|
+
)
|
|
400
|
+
elif kind == "measurement":
|
|
401
|
+
unit_name = rng.choice(
|
|
402
|
+
("kg", "g", "km", "cm", "m²", "m³", "°C", "ms", "kWh", "L")
|
|
403
|
+
)
|
|
404
|
+
text = f"{item}: {money(number)} {unit_name}"
|
|
405
|
+
elif kind == "arithmetic":
|
|
406
|
+
a, b = rng.randrange(-10000, 10000), rng.randrange(1, 1000)
|
|
407
|
+
operator = rng.choice(("+", "-", "×"))
|
|
408
|
+
answer = a + b if operator == "+" else a - b if operator == "-" else a * b
|
|
409
|
+
text = f"{a} {operator} {b} = {answer}"
|
|
410
|
+
else:
|
|
411
|
+
text = (
|
|
412
|
+
f"user{number}+{locale}@example.org | "
|
|
413
|
+
f"+964 7{rng.randrange(1_000_000_000):09}"
|
|
414
|
+
)
|
|
415
|
+
if locale in {"ckb", "ar", "fa"} and rng.random() < 0.4:
|
|
416
|
+
digits = "۰۱۲۳۴۵۶۷۸۹" if locale == "fa" else "٠١٢٣٤٥٦٧٨٩"
|
|
417
|
+
text = text.translate(str.maketrans("0123456789", digits))
|
|
418
|
+
row = {
|
|
419
|
+
"text": text,
|
|
420
|
+
"genre": kind,
|
|
421
|
+
"language": locale,
|
|
422
|
+
"attribution": {
|
|
423
|
+
"source": "SynthOCR offline templates v2",
|
|
424
|
+
"source_id": f"{seed}:{index}",
|
|
425
|
+
"synthetic": True,
|
|
426
|
+
"seed": seed,
|
|
427
|
+
"license": "CC0-1.0",
|
|
428
|
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
|
429
|
+
"modified": False,
|
|
430
|
+
},
|
|
431
|
+
}
|
|
432
|
+
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
433
|
+
os.link(temporary, output)
|
|
434
|
+
finally:
|
|
435
|
+
if temporary is not None:
|
|
436
|
+
temporary.unlink(missing_ok=True)
|
|
437
|
+
return {"output": str(output), "records": count, "seed": seed, "languages": languages}
|