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.
@@ -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}