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/pipeline.py ADDED
@@ -0,0 +1,512 @@
1
+ """Bounded generation and streaming exports, independent of any training framework."""
2
+
3
+ import csv
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import multiprocessing
8
+ import os
9
+ import platform
10
+ import random
11
+ import shutil
12
+ import sqlite3
13
+ import tempfile
14
+ import time
15
+ from collections.abc import Callable
16
+ from concurrent.futures import ProcessPoolExecutor
17
+ from dataclasses import asdict, replace
18
+ from math import gcd
19
+ from pathlib import Path
20
+
21
+ from . import __version__
22
+ from .config import Config
23
+ from .corpus_store import canonical_language, iter_origins
24
+ from .downloads import sha256
25
+ from .fonts import Coverage, discover, font_ids, select
26
+ from .render import initialize, release, render_task, require_shaping
27
+ from .spelling import write_spelling
28
+ from .text import prepare
29
+ from .verify import verified_records
30
+
31
+ LOG = logging.getLogger(__name__)
32
+
33
+
34
+ class GenerationCancelled(ValueError):
35
+ """A cooperative stop leaves its partial dataset marked as cancelled."""
36
+
37
+
38
+ def _check_cancel(cancel):
39
+ if cancel is not None and cancel():
40
+ raise GenerationCancelled("Generation cancelled; partial output has been retained")
41
+
42
+
43
+ def _font_name(config, face):
44
+ path = Path(face.path)
45
+ try:
46
+ return path.relative_to(config.root).as_posix()
47
+ except ValueError:
48
+ return path.name
49
+
50
+
51
+ COLUMNS = "texts.id,text,source,line,genre,language,attribution"
52
+
53
+
54
+ def _selection(db, config, languages, table):
55
+ """Return the matching record count and a lookup by 1-based selection position."""
56
+ if not languages and not config.generation.exclude_genres:
57
+ count = db.execute("SELECT count(*) FROM texts").fetchone()[0]
58
+ return count, f"SELECT {COLUMNS} FROM texts WHERE texts.id=?"
59
+ # Renumber the selected source IDs on disk for constant-memory permutation.
60
+ # Keep original text IDs in manifests, font choices and deterministic seeds.
61
+ db.execute("PRAGMA temp_store=FILE")
62
+ db.execute(f"CREATE TEMP TABLE {table} (id INTEGER PRIMARY KEY, text_id INTEGER NOT NULL)")
63
+ conditions, parameters = [], []
64
+ if languages:
65
+ placeholders = ",".join("?" for _ in languages)
66
+ conditions.append(f"lower(language) IN ({placeholders})")
67
+ parameters.extend(canonical_language(language) for language in languages)
68
+ if config.generation.exclude_genres:
69
+ placeholders = ",".join("?" for _ in config.generation.exclude_genres)
70
+ conditions.append(f"coalesce(genre,'') NOT IN ({placeholders})")
71
+ parameters.extend(config.generation.exclude_genres)
72
+ db.execute(
73
+ f"INSERT INTO {table}(text_id) SELECT id FROM texts "
74
+ f"WHERE {' AND '.join(conditions)} ORDER BY id",
75
+ parameters,
76
+ )
77
+ count = db.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
78
+ return count, (
79
+ f"SELECT {COLUMNS} FROM texts JOIN {table} ON texts.id={table}.text_id WHERE {table}.id=?"
80
+ )
81
+
82
+
83
+ def _candidates(config, db, fonts, stats, count, query, rng, coverage, cancel):
84
+ """Yield supported (record, face, variant) pairs in a seeded constant-memory order."""
85
+ if not count:
86
+ return
87
+ start = rng.randrange(count)
88
+ stride = rng.randrange(1, count + 1)
89
+ while gcd(stride, count) != 1:
90
+ stride = stride % count + 1
91
+ for variant in range(len(fonts)):
92
+ found = False
93
+ for index in range(count):
94
+ _check_cancel(cancel)
95
+ position = (start + index * stride) % count + 1
96
+ row = db.execute(query, (position,)).fetchone()
97
+ text_id, text = row[0], row[1]
98
+ offset = (text_id + config.generation.seed) % len(fonts)
99
+ if config.fonts.coverage == "strict":
100
+ face = coverage.choose(fonts, text, offset, variant)
101
+ else:
102
+ face = fonts[(offset + variant) % len(fonts)]
103
+ if face is None:
104
+ if variant == 0:
105
+ stats["unsupported_texts"] += 1
106
+ if len(stats["unsupported_examples"]) < 5:
107
+ stats["unsupported_examples"].append({"text_id": text_id, "text": text})
108
+ continue
109
+ found = True
110
+ yield row, face, variant
111
+ if not found:
112
+ return
113
+
114
+
115
+ def _quotas(names, weights, total):
116
+ """Divide total by relative weight, giving remainders to the largest fractions."""
117
+ values = [weights.get(name, 1) for name in names]
118
+ exact = [total * value / sum(values) for value in values]
119
+ quotas = [int(value) for value in exact]
120
+ order = sorted(range(len(names)), key=lambda i: (quotas[i] - exact[i], i))
121
+ for index in order[: total - sum(quotas)]:
122
+ quotas[index] += 1
123
+ return quotas
124
+
125
+
126
+ def _interleave(streams, quotas, total):
127
+ """Draw each stream up to its quota, spread evenly; an exhausted stream's
128
+ remainder is filled from the others so the requested total is still reached."""
129
+ produced = [0] * len(streams)
130
+ active = set(range(len(streams)))
131
+ drawn = 0
132
+ while drawn < total and active:
133
+ owed = [i for i in active if produced[i] < quotas[i]]
134
+ candidates = owed or sorted(active)
135
+ # Pick the stream furthest behind its proportional target at this point.
136
+ index = max(
137
+ candidates,
138
+ key=lambda i: (quotas[i] * (drawn + 1) / total - produced[i], -i),
139
+ )
140
+ item = next(streams[index], None)
141
+ if item is None:
142
+ active.discard(index)
143
+ continue
144
+ produced[index] += 1
145
+ drawn += 1
146
+ yield item
147
+
148
+
149
+ def _tasks(config, db, fonts, output, stats, font_ids, cancel=None):
150
+ coverage = Coverage(config.fonts.coverage_cache_size)
151
+ languages = config.generation.languages
152
+ weights = {
153
+ canonical_language(name): value
154
+ for name, value in config.generation.language_weights.items()
155
+ }
156
+ selected = list(dict.fromkeys(canonical_language(language) for language in languages))
157
+ if weights:
158
+ # A zero share excludes that language; it is never used to fill gaps.
159
+ selected = [language for language in selected if weights.get(language, 1) > 0]
160
+ if weights and len(selected) > 1:
161
+ streams, counts = [], []
162
+ for index, language in enumerate(selected):
163
+ count, query = _selection(db, config, [language], f"selected_{index}")
164
+ counts.append(count)
165
+ rng = random.Random(f"{config.generation.seed}:{language}")
166
+ streams.append(
167
+ _candidates(config, db, fonts, stats, count, query, rng, coverage, cancel)
168
+ )
169
+ if not any(counts):
170
+ raise ValueError(
171
+ "No processed records match the requested languages/genres; check source metadata"
172
+ )
173
+ candidates = _interleave(
174
+ streams,
175
+ _quotas(selected, weights, config.generation.samples),
176
+ config.generation.samples,
177
+ )
178
+ else:
179
+ count, query = _selection(db, config, selected if weights else languages, "selected")
180
+ if not count:
181
+ raise ValueError(
182
+ "No processed records match the requested languages/genres; check source metadata"
183
+ )
184
+ rng = random.Random(config.generation.seed)
185
+ candidates = _candidates(config, db, fonts, stats, count, query, rng, coverage, cancel)
186
+ for submitted, (row, face, variant) in enumerate(candidates):
187
+ text_id, text, source, line, genre, language, attribution = row
188
+ sample_id = f"{submitted:08d}"
189
+ seed = int.from_bytes(
190
+ hashlib.sha256(f"{config.generation.seed}:{text_id}:{variant}".encode()).digest()[:8]
191
+ )
192
+ yield {
193
+ "id": sample_id,
194
+ "text_id": text_id,
195
+ "text": text,
196
+ "source": source,
197
+ "line": line,
198
+ "genre": genre,
199
+ "language": language,
200
+ "face": face,
201
+ "font_name": _font_name(config, face),
202
+ "font_id": font_ids[face],
203
+ "attribution": json.loads(attribution),
204
+ "seed": seed,
205
+ "destination": str(output / "images" / f"{sample_id}.{config.generation.format}"),
206
+ }
207
+ # Stop before drawing another candidate, which could count unsupported text.
208
+ if submitted + 1 == config.generation.samples:
209
+ return
210
+
211
+
212
+ def generate(
213
+ config: Config,
214
+ *,
215
+ progress: Callable[[dict], None] | None = None,
216
+ cancel: Callable[[], bool] | None = None,
217
+ ) -> dict:
218
+ config.validate()
219
+ if config.generation.suite == "kurdish-spelling":
220
+ with tempfile.TemporaryDirectory(prefix="synthocr-spelling-") as directory:
221
+ raw, processed = Path(directory) / "raw", Path(directory) / "processed"
222
+ write_spelling(raw / "spelling.jsonl")
223
+ return _generate(
224
+ replace(
225
+ config,
226
+ data=replace(
227
+ config.data,
228
+ raw=str(raw),
229
+ processed=str(processed),
230
+ source_languages={},
231
+ ),
232
+ ),
233
+ progress=progress,
234
+ cancel=cancel,
235
+ )
236
+ return _generate(config, progress=progress, cancel=cancel)
237
+
238
+
239
+ def _generate(config: Config, *, progress=None, cancel=None) -> dict:
240
+ config.validate()
241
+ _check_cancel(cancel)
242
+ if progress:
243
+ progress({"stage": "preparing", "generated": 0, "requested": config.generation.samples})
244
+ require_shaping()
245
+ prepared = prepare(config)
246
+ _check_cancel(cancel)
247
+ fonts = discover([config.path(p) for p in config.fonts.directories])
248
+ face_ids = font_ids(fonts)
249
+ fonts = select(fonts, config.fonts.include, face_ids)
250
+ _check_cancel(cancel)
251
+ output = config.path(config.generation.output)
252
+ if output.exists() and any(output.iterdir()):
253
+ raise ValueError(f"Output directory must be empty: {output}. Choose a new --output.")
254
+ output.mkdir(parents=True, exist_ok=True)
255
+ (output / "images").mkdir()
256
+ started = time.perf_counter()
257
+ stats = {
258
+ "requested": config.generation.samples,
259
+ "generated": 0,
260
+ "fonts": len(fonts),
261
+ "unsupported_texts": 0,
262
+ "unsupported_examples": [],
263
+ "status": "running",
264
+ }
265
+ run = {
266
+ "schema_version": 3,
267
+ "version": __version__,
268
+ "python": platform.python_version(),
269
+ "config": {
270
+ "data": asdict(config.data),
271
+ "fonts": asdict(config.fonts),
272
+ "generation": asdict(config.generation),
273
+ },
274
+ "corpus_fingerprint": prepared["fingerprint"],
275
+ "corpus_sources": prepared["sources"],
276
+ "font_files": [],
277
+ }
278
+ for face in fonts:
279
+ digest = face_ids[face].rsplit(":", 1)[0]
280
+ run["font_files"].append(
281
+ {
282
+ "font": _font_name(config, face),
283
+ "face_index": face.index,
284
+ "sha256": digest,
285
+ "id": face_ids[face],
286
+ }
287
+ )
288
+ (output / "run.json").write_text(
289
+ json.dumps(run, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
290
+ )
291
+ db_path = config.path(config.data.processed) / "texts.sqlite3"
292
+ db = sqlite3.connect(db_path.as_uri() + "?mode=ro", uri=True)
293
+ db.execute("PRAGMA cache_size=-4096")
294
+ db.execute("PRAGMA temp_store=FILE")
295
+ db.execute("CREATE TEMP TABLE emitted_origins (text_id INTEGER PRIMARY KEY)")
296
+ executor = None
297
+ try:
298
+ actual_fingerprint = db.execute("SELECT fingerprint FROM metadata").fetchone()[0]
299
+ if actual_fingerprint != prepared["fingerprint"]:
300
+ raise ValueError(
301
+ "Processed corpus changed before generation; retry with a stable corpus"
302
+ )
303
+ if progress:
304
+ progress({"stage": "generating", "generated": 0, "requested": stats["requested"]})
305
+ tasks = _tasks(config, db, fonts, output, stats, face_ids, cancel)
306
+ if config.generation.workers == 1:
307
+ initialize(config.generation, config.fonts.cache_size)
308
+ results = map(render_task, tasks)
309
+ else:
310
+ # Spawn avoids unsafe forking of native font engines on macOS.
311
+ executor = ProcessPoolExecutor(
312
+ max_workers=config.generation.workers,
313
+ mp_context=multiprocessing.get_context("spawn"),
314
+ initializer=initialize,
315
+ initargs=(config.generation, config.fonts.cache_size),
316
+ )
317
+ results = executor.map(
318
+ render_task, tasks, chunksize=1, buffersize=2 * config.generation.workers
319
+ )
320
+ with (
321
+ (output / "manifest.jsonl").open("w", encoding="utf-8") as manifest,
322
+ (output / "origins.jsonl").open("w", encoding="utf-8") as origins,
323
+ ):
324
+ for record in results:
325
+ _check_cancel(cancel)
326
+ manifest.write(json.dumps(record, ensure_ascii=False) + "\n")
327
+ inserted = db.execute(
328
+ "INSERT OR IGNORE INTO emitted_origins VALUES(?)", (record["text_id"],)
329
+ ).rowcount
330
+ if inserted:
331
+ for origin in iter_origins(db, record["text_id"]):
332
+ origin["text_sha256"] = hashlib.sha256(record["text"].encode()).hexdigest()
333
+ origins.write(json.dumps(origin, ensure_ascii=False) + "\n")
334
+ stats["generated"] += 1
335
+ if progress:
336
+ progress(
337
+ {
338
+ "stage": "generating",
339
+ "generated": stats["generated"],
340
+ "requested": stats["requested"],
341
+ }
342
+ )
343
+ if stats["generated"] % 1000 == 0:
344
+ LOG.info("Generated %s/%s images", stats["generated"], stats["requested"])
345
+ _check_cancel(cancel)
346
+ if stats["generated"] != stats["requested"]:
347
+ raise ValueError(
348
+ f"Only {stats['generated']}/{stats['requested']} unique supported "
349
+ "text/font pairs are available. Add text or fonts, or request fewer samples."
350
+ )
351
+ stats["status"] = "complete"
352
+ except BaseException as error:
353
+ stats["status"] = "cancelled" if isinstance(error, GenerationCancelled) else "failed"
354
+ stats["error"] = str(error)
355
+ raise
356
+ finally:
357
+ if executor:
358
+ executor.shutdown(wait=True, cancel_futures=True)
359
+ else:
360
+ release()
361
+ db.close()
362
+ stats["seconds"] = time.perf_counter() - started
363
+ stats["images_per_second"] = stats["generated"] / max(stats["seconds"], 1e-9)
364
+ stats["run_sha256"] = sha256(output / "run.json")
365
+ if (output / "manifest.jsonl").exists():
366
+ stats["manifest_sha256"] = sha256(output / "manifest.jsonl")
367
+ if (output / "origins.jsonl").exists():
368
+ stats["origins_sha256"] = sha256(output / "origins.jsonl")
369
+ (output / "stats.json").write_text(
370
+ json.dumps(stats, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
371
+ )
372
+ return stats | {"output": str(output)}
373
+
374
+
375
+ def export(dataset: Path, output: Path, format: str, *, max_bytes: int | None = None) -> dict:
376
+ """Publish an attributed view atomically, optionally limiting apparent bytes.
377
+
378
+ Hardlinked images count toward the limit just like copied images. This
379
+ bounds temporary exports as well as their eventual downloadable content.
380
+ """
381
+ from .verify import dataset_files
382
+
383
+ if format not in {"csv", "imagefolder", "tesseract"}:
384
+ raise ValueError("Export format must be csv, imagefolder or tesseract")
385
+ if max_bytes is not None and (type(max_bytes) is not int or max_bytes < 0):
386
+ raise ValueError("max_bytes must be a nonnegative integer or None")
387
+ dataset = dataset.resolve()
388
+ output = output.resolve()
389
+ if output.exists() and any(output.iterdir()):
390
+ raise ValueError(f"Export destination must be empty: {output}")
391
+ consumed = 0
392
+
393
+ def check(size):
394
+ if max_bytes is not None and consumed + size > max_bytes:
395
+ raise ValueError(f"Export exceeds the configured byte limit ({max_bytes} bytes)")
396
+
397
+ def charge(size):
398
+ nonlocal consumed
399
+ check(size)
400
+ consumed += size
401
+
402
+ def write_text(path, value):
403
+ if max_bytes is not None:
404
+ charge(len(value.encode("utf-8")))
405
+ path.write_text(value, encoding="utf-8", newline="")
406
+
407
+ def copy_file(source, destination):
408
+ if max_bytes is None:
409
+ with source.open("rb") as src, destination.open("xb") as dst:
410
+ shutil.copyfileobj(src, dst, 1024 * 1024)
411
+ return
412
+ check(source.stat().st_size)
413
+ with source.open("rb") as src, destination.open("xb") as dst:
414
+ while value := src.read(min(1024 * 1024, max_bytes - consumed + 1)):
415
+ charge(len(value))
416
+ dst.write(value)
417
+
418
+ class Labels:
419
+ def __init__(self, stream):
420
+ self.stream = stream
421
+
422
+ def write(self, value):
423
+ if max_bytes is not None:
424
+ charge(len(value.encode("utf-8")))
425
+ return self.stream.write(value)
426
+
427
+ if max_bytes is not None:
428
+ minimum = 0
429
+ for path in dataset_files(dataset / "images"):
430
+ minimum += path.stat().st_size
431
+ check(minimum)
432
+ origins = dataset / "origins.jsonl"
433
+ if origins.is_symlink():
434
+ raise ValueError("Dataset artifacts must not be symbolic links")
435
+ if origins.is_file():
436
+ check(minimum + origins.stat().st_size)
437
+ output.parent.mkdir(parents=True, exist_ok=True)
438
+ count = 0
439
+ with tempfile.TemporaryDirectory(prefix=".export-", dir=output.parent) as directory:
440
+ temporary = Path(directory)
441
+ metadata_name = {
442
+ "csv": "labels.csv",
443
+ "imagefolder": "metadata.jsonl",
444
+ "tesseract": "attribution.jsonl",
445
+ }[format]
446
+ with (temporary / metadata_name).open("x", encoding="utf-8", newline="") as stream:
447
+ labels = Labels(stream)
448
+ writer = csv.DictWriter(
449
+ labels,
450
+ fieldnames=[
451
+ "image",
452
+ "text",
453
+ "text_id",
454
+ "split",
455
+ "language",
456
+ "genre",
457
+ "attribution",
458
+ ],
459
+ )
460
+ if format == "csv":
461
+ writer.writeheader()
462
+ for record, path in verified_records(dataset):
463
+ destination = temporary / f"{record['id']}{path.suffix}"
464
+ size = path.stat().st_size
465
+ check(size)
466
+ try:
467
+ os.link(path, destination)
468
+ except OSError:
469
+ copy_file(path, destination)
470
+ else:
471
+ charge(size)
472
+ if format == "csv":
473
+ writer.writerow(
474
+ {
475
+ "image": destination.name,
476
+ "text": record["text"],
477
+ "text_id": record["text_id"],
478
+ "split": record["split"],
479
+ "language": record["language"],
480
+ "genre": record["genre"],
481
+ "attribution": json.dumps(record["attribution"], ensure_ascii=False),
482
+ }
483
+ )
484
+ else:
485
+ metadata = {k: v for k, v in record.items() if k != "image"}
486
+ metadata["file_name"] = destination.name
487
+ labels.write(json.dumps(metadata, ensure_ascii=False) + "\n")
488
+ if format == "tesseract":
489
+ write_text(destination.with_suffix(".gt.txt"), record["text"])
490
+ count += 1
491
+ provenance = {}
492
+ if (dataset / "origins.jsonl").is_file():
493
+ copy_file(dataset / "origins.jsonl", temporary / "origins.jsonl")
494
+ provenance["origins_sha256"] = sha256(temporary / "origins.jsonl")
495
+ write_text(
496
+ temporary / "export.json",
497
+ json.dumps(
498
+ {
499
+ "status": "complete",
500
+ "records": count,
501
+ "format": format,
502
+ "source_manifest_sha256": sha256(dataset / "manifest.jsonl"),
503
+ **provenance,
504
+ },
505
+ indent=2,
506
+ )
507
+ + "\n",
508
+ )
509
+ if output.exists():
510
+ output.rmdir() # Refuses to remove anything if another writer added content.
511
+ os.rename(temporary, output)
512
+ return {"records": count, "format": format, "output": str(output)}
synthocr/preview.py ADDED
@@ -0,0 +1,112 @@
1
+ """A reproducible multilingual showcase rendered with the dataset engine."""
2
+
3
+ from pathlib import Path
4
+
5
+ from PIL import Image, ImageDraw
6
+
7
+ from .config import Config, Generation
8
+ from .fonts import Coverage, discover
9
+ from .render import Renderer
10
+
11
+ SAMPLES = (
12
+ ("English", "en", "notosans", "Make text. Train any model."),
13
+ ("Kurdish", "ckb", "notonaskharabic", "سڵاو جیهان، زانیاری بۆ هەمووان"),
14
+ ("Arabic", "ar", "amiri", "بيانات واضحة، نماذج أفضل"),
15
+ ("Persian", "fa", "notosansarabic", "داده‌های روشن برای همه"),
16
+ ("Hebrew", "he", "notosanshebrew", "מילים קטנות, אפשרויות גדולות"),
17
+ ("Urdu", "ur", "notonastaliqurdu", "کتب خانہ سب کے لیے کھلا ہے"),
18
+ ("Hindi", "hi", "notosansdevanagari", "हर शब्द एक नई संभावना है"),
19
+ ("Bengali", "bn", "notosansbengali", "সবার জন্য নতুন সম্ভাবনা"),
20
+ ("Chinese", "zh", "notosanssc", "每一种文字,都有新的可能"),
21
+ ("Japanese", "ja", "notosansjp", "ことばから、新しい可能性へ"),
22
+ ("Korean", "ko", "notosanskr", "모든 글자에 새로운 가능성"),
23
+ ("Thai", "th", "notosansthai", "ทุกตัวอักษรสร้างโอกาสใหม่"),
24
+ )
25
+
26
+
27
+ def preview(config: Config, destination: Path) -> dict:
28
+ """Use only fonts accompanied by OFL.txt; refuse to overwrite an existing file."""
29
+ if destination.exists():
30
+ raise ValueError(f"Preview already exists; choose a new output: {destination}")
31
+ fonts = [
32
+ font
33
+ for font in discover([config.path(p) for p in config.fonts.directories])
34
+ if Path(font.path).with_name("OFL.txt").is_file()
35
+ ]
36
+ if not fonts:
37
+ raise ValueError("Preview requires fonts accompanied by OFL.txt; run synthocr fonts fetch")
38
+ coverage = Coverage(config.fonts.coverage_cache_size)
39
+ renderer = Renderer(
40
+ Generation(
41
+ font_size=44,
42
+ padding=8,
43
+ max_width=686,
44
+ max_height=84,
45
+ max_pixels=686 * 84,
46
+ augmentation=0.35,
47
+ ),
48
+ config.fonts.cache_size,
49
+ )
50
+ preferred = sorted(fonts, key=lambda font: Path(font.path).parent.name != "notosans")
51
+ ui = coverage.choose(preferred, "SynthOCR / Every writing system. Any recognition model.", 0)
52
+ if ui is None:
53
+ raise ValueError("Preview requires a licensed font with Latin glyphs")
54
+ title = renderer.font(ui, 86)
55
+ subtitle = renderer.font(ui, 28)
56
+ label = renderer.font(ui, 17)
57
+ small = renderer.font(ui, 14)
58
+ image = Image.new("RGB", (1600, 1260), "#eef3f5")
59
+ try:
60
+ draw = ImageDraw.Draw(image)
61
+ draw.rectangle((0, 0, 1600, 252), fill="#10282e")
62
+ draw.polygon(((1270, 0), (1600, 0), (1600, 252), (1435, 252)), fill="#163b40")
63
+ draw.rectangle((56, 48, 62, 128), fill="#74e2c1")
64
+ draw.text((84, 25), "SynthOCR", font=title, fill="#f5faf9")
65
+ draw.text(
66
+ (60, 139),
67
+ "Every writing system. Any recognition model.",
68
+ font=subtitle,
69
+ fill="#c7ddda",
70
+ )
71
+ x = 60
72
+ for text in ("12 LANGUAGES", "BUILT-IN FONT CHECKS", "MODEL-NEUTRAL EXPORTS"):
73
+ width = int(draw.textlength(text, font=small)) + 30
74
+ draw.rounded_rectangle((x, 201, x + width, 232), radius=15, fill="#24464b")
75
+ draw.text((x + 15, 205), text, font=small, fill="#95e7cf")
76
+ x += width + 12
77
+ for index, (name, language, family, text) in enumerate(SAMPLES):
78
+ choices = sorted(fonts, key=lambda font: Path(font.path).parent.name != family)
79
+ face = coverage.choose(choices, text, 0)
80
+ if face is None:
81
+ raise ValueError(f"No licensed font covers the {name} preview; fetch catalog fonts")
82
+ x, y = 56 + (index % 2) * 754, 280 + (index // 2) * 152
83
+ draw.rounded_rectangle((x, y + 3, x + 734, y + 139), radius=16, fill="#dfe8eb")
84
+ draw.rounded_rectangle((x, y, x + 734, y + 136), radius=16, fill="white")
85
+ draw.text(
86
+ (x + 24, y + 13), f"{name.upper()} / {language}", font=label, fill="#29605d"
87
+ )
88
+ draw.text((x + 691, y + 15), f"{index + 1:02}", font=small, fill="#77918e")
89
+ sample, _ = renderer.render(text, face, 17 + index, language)
90
+ try:
91
+ image.paste(sample, (x + 24, y + 43 + (84 - sample.height) // 2))
92
+ finally:
93
+ sample.close()
94
+ draw.text(
95
+ (60, 1207),
96
+ "Real SynthOCR renders / RAQM shaping / OFL fonts / Authored sample text",
97
+ font=label,
98
+ fill="#456764",
99
+ )
100
+ destination.parent.mkdir(parents=True, exist_ok=True)
101
+ # Exclusive creation also protects against a destination created during rendering.
102
+ with destination.open("xb") as stream:
103
+ image.save(stream, format="PNG", optimize=True)
104
+ return {
105
+ "output": str(destination),
106
+ "width": image.width,
107
+ "height": image.height,
108
+ "samples": len(SAMPLES),
109
+ "languages": [sample[1] for sample in SAMPLES],
110
+ }
111
+ finally:
112
+ image.close()