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/config.py ADDED
@@ -0,0 +1,589 @@
1
+ """Small, typed TOML configuration with paths anchored to the config file."""
2
+
3
+ import re
4
+ import tomllib
5
+ from dataclasses import dataclass, field, fields, replace
6
+ from math import fsum, isfinite
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ DOCUMENT_LAYERS = (
10
+ "paper_texture",
11
+ "grain",
12
+ "shadow",
13
+ "stains",
14
+ "creases",
15
+ "ruled_lines",
16
+ "scan_banding",
17
+ "speckles",
18
+ "binding_shadow",
19
+ )
20
+ LAYER_FIELDS = DOCUMENT_LAYERS
21
+ # Plain names for the effect layers, shared by the API and the web page.
22
+ DOCUMENT_LAYER_LABELS = {
23
+ "paper_texture": ("Paper texture", "Fibers and uneven paper"),
24
+ "grain": ("Grain", "Scanner or camera noise"),
25
+ "shadow": ("Shadows", "Uneven light across the page"),
26
+ "stains": ("Stains", "Soft marks on the paper"),
27
+ "creases": ("Folds", "Light creases in the page"),
28
+ "ruled_lines": ("Notebook lines", "Horizontal writing guides"),
29
+ "scan_banding": ("Scan bands", "Faint lines from scanning or printing"),
30
+ "speckles": ("Speckles", "Paper fibers and toner dots"),
31
+ "binding_shadow": ("Book spine shadow", "Shading near a bound edge"),
32
+ }
33
+
34
+ # Recipes supply ready-made palettes and layer defaults. Explicit overrides
35
+ # remain available, and geometry settings are independent of the recipe.
36
+ DOCUMENT_PRESETS = {
37
+ "clean-paper": {
38
+ "label": "Clean paper",
39
+ "description": "Light paper fibers and a small amount of fine grain.",
40
+ "background": "plain",
41
+ "color_mode": "grayscale",
42
+ "background_color": "#f8f8f5",
43
+ "background_color_end": "#f8f8f5",
44
+ "text_color": "#202020",
45
+ "layers": dict(zip(DOCUMENT_LAYERS, (0.18, 0.03, 0, 0, 0, 0, 0, 0, 0), strict=True)),
46
+ },
47
+ "office-scan": {
48
+ "label": "Office scan",
49
+ "description": "Fine scanner grain, soft illumination and faint scan bands.",
50
+ "background": "plain",
51
+ "color_mode": "grayscale",
52
+ "background_color": "#f4f4f4",
53
+ "background_color_end": "#f4f4f4",
54
+ "text_color": "#202020",
55
+ "layers": dict(zip(DOCUMENT_LAYERS, (0.12, 0.22, 0.16, 0, 0, 0, 0.12, 0, 0), strict=True)),
56
+ },
57
+ "aged-paper": {
58
+ "label": "Aged paper",
59
+ "description": "Mottled fibers, faded stains and light fold marks.",
60
+ "background": "plain",
61
+ "color_mode": "rgb",
62
+ "background_color": "#ecdeb9",
63
+ "background_color_end": "#ecdeb9",
64
+ "text_color": "#342a1e",
65
+ "layers": dict(
66
+ zip(DOCUMENT_LAYERS, (0.5, 0.14, 0.15, 0.55, 0.2, 0, 0, 0.12, 0), strict=True)
67
+ ),
68
+ },
69
+ "notebook": {
70
+ "label": "Notebook",
71
+ "description": "Ruled writing paper with a soft paper texture.",
72
+ "background": "plain",
73
+ "color_mode": "rgb",
74
+ "background_color": "#f7f5ea",
75
+ "background_color_end": "#f7f5ea",
76
+ "text_color": "#242c35",
77
+ "layers": dict(zip(DOCUMENT_LAYERS, (0.22, 0.06, 0, 0, 0, 0.6, 0, 0, 0), strict=True)),
78
+ },
79
+ "phone-photo": {
80
+ "label": "Phone photo",
81
+ "description": "Directional lighting, soft edge shadows and camera grain.",
82
+ "background": "gradient",
83
+ "color_mode": "rgb",
84
+ "background_color": "#f3f0e6",
85
+ "background_color_end": "#d9d6ca",
86
+ "text_color": "#252a2b",
87
+ "layers": dict(zip(DOCUMENT_LAYERS, (0.16, 0.2, 0.72, 0, 0.08, 0, 0, 0, 0), strict=True)),
88
+ },
89
+ "photocopy": {
90
+ "label": "Photocopy",
91
+ "description": "Stronger copy grain with subtle uneven toner density.",
92
+ "background": "plain",
93
+ "color_mode": "grayscale",
94
+ "background_color": "#eeeeee",
95
+ "background_color_end": "#eeeeee",
96
+ "text_color": "#232323",
97
+ "layers": dict(
98
+ zip(DOCUMENT_LAYERS, (0.3, 0.7, 0.22, 0.06, 0, 0, 0.2, 0.25, 0), strict=True)
99
+ ),
100
+ },
101
+ "thermal-receipt": {
102
+ "label": "Thermal receipt",
103
+ "description": "Warm receipt paper with fine horizontal printer bands.",
104
+ "background": "plain",
105
+ "color_mode": "rgb",
106
+ "background_color": "#f4eee0",
107
+ "background_color_end": "#f4eee0",
108
+ "text_color": "#3c3933",
109
+ "layers": dict(
110
+ zip(DOCUMENT_LAYERS, (0.12, 0.12, 0.08, 0, 0.08, 0, 0.6, 0.03, 0), strict=True)
111
+ ),
112
+ },
113
+ "newsprint": {
114
+ "label": "Newsprint",
115
+ "description": "Gray recycled newsprint with fibers and fine paper flecks.",
116
+ "background": "plain",
117
+ "color_mode": "rgb",
118
+ "background_color": "#d9d7cb",
119
+ "background_color_end": "#d9d7cb",
120
+ "text_color": "#2c2b27",
121
+ "layers": dict(zip(DOCUMENT_LAYERS, (0.6, 0.3, 0.08, 0, 0, 0, 0, 0.55, 0), strict=True)),
122
+ },
123
+ "recycled-paper": {
124
+ "label": "Recycled paper",
125
+ "description": "Natural beige paper with visible fibers and uneven flecks.",
126
+ "background": "plain",
127
+ "color_mode": "rgb",
128
+ "background_color": "#e6ddc8",
129
+ "background_color_end": "#e6ddc8",
130
+ "text_color": "#302d25",
131
+ "layers": dict(zip(DOCUMENT_LAYERS, (0.65, 0.12, 0.06, 0.1, 0, 0, 0, 0.8, 0), strict=True)),
132
+ },
133
+ "book-page": {
134
+ "label": "Book page",
135
+ "description": "Cream book paper with a soft shadow near the binding.",
136
+ "background": "plain",
137
+ "color_mode": "rgb",
138
+ "background_color": "#f3ead5",
139
+ "background_color_end": "#f3ead5",
140
+ "text_color": "#302a23",
141
+ "layers": dict(
142
+ zip(DOCUMENT_LAYERS, (0.25, 0.08, 0.08, 0, 0, 0, 0, 0.04, 0.7), strict=True)
143
+ ),
144
+ },
145
+ "fax": {
146
+ "label": "Fax",
147
+ "description": "Grayscale fax paper with horizontal bands and scattered toner.",
148
+ "background": "plain",
149
+ "color_mode": "grayscale",
150
+ "background_color": "#f0f0ee",
151
+ "background_color_end": "#f0f0ee",
152
+ "text_color": "#292929",
153
+ "layers": dict(zip(DOCUMENT_LAYERS, (0.08, 0.5, 0.06, 0, 0, 0, 0.8, 0.45, 0), strict=True)),
154
+ },
155
+ "crumpled-paper": {
156
+ "label": "Crumpled paper",
157
+ "description": "Soft diagonal folds and creases on lightly textured paper.",
158
+ "background": "plain",
159
+ "color_mode": "rgb",
160
+ "background_color": "#eeeadd",
161
+ "background_color_end": "#eeeadd",
162
+ "text_color": "#2b2b26",
163
+ "layers": dict(
164
+ zip(DOCUMENT_LAYERS, (0.3, 0.1, 0.2, 0.04, 0.8, 0, 0, 0.05, 0), strict=True)
165
+ ),
166
+ },
167
+ }
168
+
169
+ AUGMENTATION_FIELDS = (
170
+ "augmentation",
171
+ "background",
172
+ "color_mode",
173
+ "background_color",
174
+ "background_color_end",
175
+ "text_color",
176
+ "texture_strength",
177
+ "rotation",
178
+ "blur",
179
+ "contrast",
180
+ "background_presets",
181
+ "background_weights",
182
+ "preset_colors",
183
+ *DOCUMENT_LAYERS,
184
+ )
185
+
186
+
187
+ def background_distribution(settings) -> dict[str, float]:
188
+ """Conditional recipe shares for validated settings; missing weights are one."""
189
+ weights = {
190
+ name: float(settings.background_weights.get(name, 1))
191
+ for name in settings.background_presets
192
+ }
193
+ total = fsum(weights.values())
194
+ return {name: weight / total for name, weight in weights.items()}
195
+
196
+
197
+ def document_layers(settings, preset: str = "") -> dict[str, float]:
198
+ """Resolve explicit overrides; zero disables and None inherits a recipe."""
199
+ defaults = DOCUMENT_PRESETS[preset]["layers"] if preset else {}
200
+ return {
201
+ name: float(
202
+ defaults.get(name, 0) if getattr(settings, name) is None else getattr(settings, name)
203
+ )
204
+ for name in DOCUMENT_LAYERS
205
+ }
206
+
207
+
208
+ def resolve_document_settings(settings, preset: str = ""):
209
+ """Return a preset's ready-made palette, or retain explicit custom colors."""
210
+ if not preset or not settings.preset_colors:
211
+ return settings
212
+ recipe = DOCUMENT_PRESETS[preset]
213
+ return replace(
214
+ settings,
215
+ **{
216
+ name: recipe[name]
217
+ for name in (
218
+ "background",
219
+ "color_mode",
220
+ "background_color",
221
+ "background_color_end",
222
+ "text_color",
223
+ )
224
+ },
225
+ texture_strength=0,
226
+ )
227
+
228
+
229
+ def _rgb(value: str) -> tuple[int, int, int]:
230
+ return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
231
+
232
+
233
+ def _luminance(rgb, grayscale):
234
+ if grayscale:
235
+ gray = int(
236
+ 0.5
237
+ + sum(
238
+ channel * weight for channel, weight in zip(rgb, (0.299, 0.587, 0.114), strict=True)
239
+ )
240
+ )
241
+ rgb = (gray, gray, gray)
242
+ linear = [
243
+ value / 255 / 12.92 if value / 255 <= 0.04045 else ((value / 255 + 0.055) / 1.055) ** 2.4
244
+ for value in rgb
245
+ ]
246
+ return sum(
247
+ value * weight for value, weight in zip(linear, (0.2126, 0.7152, 0.0722), strict=True)
248
+ )
249
+
250
+
251
+ def augmentation_contrast(settings) -> float:
252
+ """Minimum base-color contrast across the actual RGB or grayscale gradient."""
253
+ foreground = _luminance(_rgb(settings.text_color), settings.color_mode == "grayscale")
254
+ start = _rgb(settings.background_color)
255
+ end = _rgb(settings.background_color_end) if settings.background == "gradient" else start
256
+ minimum = 21.0
257
+ for step in range(17):
258
+ color = tuple(int(0.5 + a + (b - a) * step / 16) for a, b in zip(start, end, strict=True))
259
+ background = _luminance(color, settings.color_mode == "grayscale")
260
+ minimum = min(
261
+ minimum, (max(foreground, background) + 0.05) / (min(foreground, background) + 0.05)
262
+ )
263
+ return minimum
264
+
265
+
266
+ def validate_augmentation(settings) -> None:
267
+ for name, maximum in (
268
+ ("augmentation", 1),
269
+ ("texture_strength", 0.25),
270
+ ("rotation", 10),
271
+ ("blur", 2),
272
+ ("contrast", 0.5),
273
+ ):
274
+ value = getattr(settings, name)
275
+ if type(value) not in {int, float} or not isfinite(value) or not 0 <= value <= maximum:
276
+ raise ValueError(f"{name} must be a finite number between 0 and {maximum}")
277
+ presets = settings.background_presets
278
+ if (
279
+ not isinstance(presets, list)
280
+ or len(presets) > len(DOCUMENT_PRESETS)
281
+ or any(not isinstance(value, str) or value not in DOCUMENT_PRESETS for value in presets)
282
+ or len(set(presets)) != len(presets)
283
+ ):
284
+ raise ValueError("background_presets must be a list of distinct supported document presets")
285
+ weights = settings.background_weights
286
+ if not isinstance(weights, dict) or len(weights) > len(DOCUMENT_PRESETS):
287
+ raise ValueError("background_weights must be a mapping of selected presets to weights")
288
+ for name, value in weights.items():
289
+ if name not in presets:
290
+ raise ValueError("background_weights keys must be selected background_presets")
291
+ if type(value) not in {int, float} or not 0 <= value <= 1_000_000 or not isfinite(value):
292
+ raise ValueError(
293
+ "background_weights values must be finite numbers between 0 and 1000000"
294
+ )
295
+ if presets and not any(weights.get(name, 1) > 0 for name in presets):
296
+ raise ValueError(
297
+ "background_weights must give at least one selected preset a positive weight"
298
+ )
299
+ if type(settings.preset_colors) is not bool:
300
+ raise ValueError("preset_colors must be a boolean")
301
+ for name in DOCUMENT_LAYERS:
302
+ value = getattr(settings, name)
303
+ if value is not None and (
304
+ type(value) not in {int, float} or not isfinite(value) or not 0 <= value <= 1
305
+ ):
306
+ raise ValueError(f"{name} must be null or a finite number between 0 and 1")
307
+ if not isinstance(settings.background, str) or settings.background not in {
308
+ "paper",
309
+ "plain",
310
+ "gradient",
311
+ "noise",
312
+ }:
313
+ raise ValueError("background must be paper, plain, gradient or noise")
314
+ if not isinstance(settings.color_mode, str) or settings.color_mode not in {"grayscale", "rgb"}:
315
+ raise ValueError("color_mode must be grayscale or rgb")
316
+ for name in ("background_color", "background_color_end", "text_color"):
317
+ value = getattr(settings, name)
318
+ if not isinstance(value, str) or not re.fullmatch(r"#[0-9A-Fa-f]{6}", value):
319
+ raise ValueError(f"{name} must be a six-digit hex color, such as #f5f1e8")
320
+ # Preset palettes are bundled reviewed values. Explicit palettes retain the
321
+ # same guard as legacy custom backgrounds, including intermediate gradients.
322
+ if (
323
+ settings.augmentation > 0
324
+ and (not presets or not settings.preset_colors)
325
+ and augmentation_contrast(settings) < 3
326
+ ):
327
+ raise ValueError(
328
+ "Text and background colors need at least 3:1 base contrast across the background; "
329
+ "choose lighter paper or darker text"
330
+ )
331
+
332
+
333
+ @dataclass(frozen=True)
334
+ class Data:
335
+ raw: str = "data/raw"
336
+ processed: str = "data/processed"
337
+ max_chars: int = 100
338
+ normalization: str = "NFC"
339
+ deduplicate: bool = True
340
+ write_jsonl: bool = False
341
+ max_line_chars: int = 1_000_000
342
+ source_languages: dict[str, str] = field(default_factory=dict)
343
+
344
+
345
+ @dataclass(frozen=True)
346
+ class Fonts:
347
+ directories: list[str] = field(default_factory=lambda: ["fonts"])
348
+ coverage: str = "strict"
349
+ cache_size: int = 32
350
+ coverage_cache_size: int = 128
351
+ # Fonts to use, by file name, "name#face" or content ID; empty uses all.
352
+ include: list[str] = field(default_factory=list)
353
+
354
+
355
+ @dataclass(frozen=True)
356
+ class Generation:
357
+ output: str = "dataset/default"
358
+ samples: int = 1000
359
+ seed: int = 42
360
+ workers: int = 1
361
+ font_size: int = 48
362
+ padding: int = 12
363
+ max_width: int = 4096
364
+ max_height: int = 512
365
+ max_pixels: int = 2_097_152
366
+ format: str = "png"
367
+ direction: str = "auto"
368
+ language: str = ""
369
+ languages: list[str] = field(default_factory=list)
370
+ # Relative share of each selected language; empty draws from all of them as one pool.
371
+ language_weights: dict[str, float] = field(default_factory=dict)
372
+ exclude_genres: list[str] = field(default_factory=list)
373
+ suite: str = ""
374
+ augmentation: float = 0.25
375
+ background: str = "paper"
376
+ color_mode: str = "grayscale"
377
+ background_color: str = "#f5f1e8"
378
+ background_color_end: str = "#e4e9ed"
379
+ text_color: str = "#1a1a1a"
380
+ texture_strength: float = 0.04
381
+ rotation: float = 2
382
+ blur: float = 0.55
383
+ contrast: float = 0.1
384
+ background_presets: list[str] = field(default_factory=list)
385
+ background_weights: dict[str, float] = field(default_factory=dict)
386
+ preset_colors: bool = True
387
+ paper_texture: float | None = None
388
+ grain: float | None = None
389
+ shadow: float | None = None
390
+ stains: float | None = None
391
+ creases: float | None = None
392
+ ruled_lines: float | None = None
393
+ scan_banding: float | None = None
394
+ speckles: float | None = None
395
+ binding_shadow: float | None = None
396
+ train_ratio: float = 0.9
397
+ validation_ratio: float = 0.05
398
+
399
+
400
+ # Convenience starting points for the UI. These are complete ordinary appearance
401
+ # settings, so editing a preset uses the same validation and renderer as custom
402
+ # settings. Keep API and CLI defaults independent for backwards compatibility.
403
+ IMAGE_PRESETS = {
404
+ identifier: {
405
+ "label": label,
406
+ "description": description,
407
+ "settings": {name: getattr(settings, name) for name in AUGMENTATION_FIELDS},
408
+ }
409
+ for identifier, label, description, settings in (
410
+ (
411
+ "balanced",
412
+ "Balanced",
413
+ "A mix of clean images, scanned pages, aged paper and phone photos.",
414
+ Generation(
415
+ augmentation=0.75,
416
+ background_presets=["office-scan", "aged-paper", "phone-photo"],
417
+ ),
418
+ ),
419
+ (
420
+ "clean",
421
+ "Clean",
422
+ "Black text on white, with no image effects.",
423
+ Generation(augmentation=0),
424
+ ),
425
+ (
426
+ "varied",
427
+ "Varied",
428
+ "All document styles, with image effects applied to every image.",
429
+ Generation(augmentation=1, background_presets=list(DOCUMENT_PRESETS)),
430
+ ),
431
+ )
432
+ }
433
+
434
+
435
+ @dataclass(frozen=True)
436
+ class Config:
437
+ root: Path
438
+ data: Data = field(default_factory=Data)
439
+ fonts: Fonts = field(default_factory=Fonts)
440
+ generation: Generation = field(default_factory=Generation)
441
+
442
+ def path(self, value: str) -> Path:
443
+ return (self.root / value).resolve()
444
+
445
+ def validate(self) -> Config:
446
+ positive = {
447
+ "max_chars": self.data.max_chars,
448
+ "max_line_chars": self.data.max_line_chars,
449
+ "samples": self.generation.samples,
450
+ "font_size": self.generation.font_size,
451
+ "max_width": self.generation.max_width,
452
+ "max_height": self.generation.max_height,
453
+ "max_pixels": self.generation.max_pixels,
454
+ "cache_size": self.fonts.cache_size,
455
+ "coverage_cache_size": self.fonts.coverage_cache_size,
456
+ }
457
+ for name, value in positive.items():
458
+ if type(value) is not int or value <= 0:
459
+ raise ValueError(f"{name} must be a positive integer")
460
+ g = self.generation
461
+ validate_augmentation(g)
462
+ if type(g.workers) is not int or not 1 <= g.workers <= 16:
463
+ raise ValueError("workers must be between 1 and 16")
464
+ if type(g.seed) is not int:
465
+ raise ValueError("seed must be an integer")
466
+ if type(g.padding) is not int or not 0 <= g.padding < min(g.max_width, g.max_height) / 2:
467
+ raise ValueError("padding must be nonnegative and fit inside the image")
468
+ if (
469
+ g.font_size > 1024
470
+ or g.max_pixels > 16_777_216
471
+ or self.fonts.cache_size > 256
472
+ or self.fonts.coverage_cache_size > 512
473
+ ):
474
+ raise ValueError("font_size, max_pixels or cache_size exceeds the memory safety limit")
475
+ for value in (g.augmentation, g.train_ratio, g.validation_ratio):
476
+ if not isinstance(value, (float, int)) or not isfinite(value) or not 0 <= value <= 1:
477
+ raise ValueError("augmentation and split ratios must be finite values in [0, 1]")
478
+ if g.train_ratio + g.validation_ratio > 1:
479
+ raise ValueError("train_ratio + validation_ratio must be <= 1")
480
+ if g.format not in {"png", "jpg", "webp", "tif"}:
481
+ raise ValueError("format must be png, jpg, webp or tif")
482
+ if g.direction not in {"auto", "ltr", "rtl", "ttb"}:
483
+ raise ValueError("direction must be auto, ltr, rtl or ttb")
484
+ if self.fonts.coverage not in {"strict", "off"}:
485
+ raise ValueError("coverage must be strict or off")
486
+ if (
487
+ not isinstance(self.fonts.include, list)
488
+ or len(self.fonts.include) > 1000
489
+ or any(
490
+ not isinstance(value, str) or not 0 < len(value) <= 300
491
+ for value in self.fonts.include
492
+ )
493
+ ):
494
+ raise ValueError("fonts.include must be a list of at most 1000 font names or IDs")
495
+ if not isinstance(g.suite, str) or g.suite not in {"", "kurdish-spelling"}:
496
+ raise ValueError("suite must be empty or kurdish-spelling")
497
+ if self.data.normalization not in {"NFC", "NFD", "NFKC", "NFKD", "none"}:
498
+ raise ValueError("Unknown Unicode normalization")
499
+ if type(self.data.deduplicate) is not bool:
500
+ raise ValueError("deduplicate must be a boolean")
501
+ if type(self.data.write_jsonl) is not bool:
502
+ raise ValueError("write_jsonl must be a boolean")
503
+
504
+ def valid_language(value):
505
+ return isinstance(value, str) and re.fullmatch(
506
+ r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", value
507
+ )
508
+
509
+ if (
510
+ not isinstance(g.languages, list)
511
+ or len(g.languages) > 100
512
+ or any(not valid_language(value) for value in g.languages)
513
+ ):
514
+ raise ValueError("languages must be a list of at most 100 language tags")
515
+ if (
516
+ not isinstance(g.exclude_genres, list)
517
+ or len(g.exclude_genres) > 100
518
+ or any(not isinstance(value, str) or not value for value in g.exclude_genres)
519
+ ):
520
+ raise ValueError("exclude_genres must be a list of at most 100 nonempty genre names")
521
+ weights = g.language_weights
522
+ if not isinstance(weights, dict) or len(weights) > 100:
523
+ raise ValueError("language_weights must map selected languages to weights")
524
+ if weights:
525
+ from .corpus_store import canonical_language
526
+
527
+ selected = {canonical_language(value) for value in g.languages}
528
+ keys = [canonical_language(name) if valid_language(name) else "" for name in weights]
529
+ if not all(keys) or len(set(keys)) != len(keys) or not set(keys) <= selected:
530
+ raise ValueError("language_weights keys must be distinct selected languages")
531
+ if any(
532
+ type(value) not in {int, float}
533
+ or not isfinite(value)
534
+ or not 0 <= value <= 1_000_000
535
+ for value in weights.values()
536
+ ):
537
+ raise ValueError(
538
+ "language_weights values must be finite numbers between 0 and 1000000"
539
+ )
540
+ # Omitted languages default to weight 1, as with background_weights.
541
+ canonical = dict(zip(keys, weights.values(), strict=True))
542
+ if not any(canonical.get(language, 1) > 0 for language in selected):
543
+ raise ValueError(
544
+ "language_weights must give at least one language a positive weight"
545
+ )
546
+ if not isinstance(self.data.source_languages, dict):
547
+ raise ValueError("source_languages must map relative raw source paths to language tags")
548
+ for source, language in self.data.source_languages.items():
549
+ if (
550
+ not isinstance(source, str)
551
+ or not source
552
+ or PurePosixPath(source).is_absolute()
553
+ or ".." in PurePosixPath(source).parts
554
+ or "\\" in source
555
+ or PurePosixPath(source).as_posix() != source
556
+ or not valid_language(language)
557
+ ):
558
+ raise ValueError(
559
+ "source_languages must map relative raw source paths to language tags"
560
+ )
561
+ if not isinstance(self.fonts.directories, list) or not self.fonts.directories:
562
+ raise ValueError("fonts.directories must be a nonempty list")
563
+ paths = [self.data.raw, self.data.processed, g.output, *self.fonts.directories]
564
+ if any(not isinstance(p, str) or not p for p in paths):
565
+ raise ValueError("Paths must be nonempty strings")
566
+ raw, processed, output = map(self.path, (self.data.raw, self.data.processed, g.output))
567
+ for a, b in ((raw, processed), (raw, output), (processed, output)):
568
+ if a == b or a in b.parents or b in a.parents:
569
+ raise ValueError("raw, processed and output paths must be separate directories")
570
+ return self
571
+
572
+
573
+ def load_config(path: str | Path = "config.toml") -> Config:
574
+ path = Path(path).resolve()
575
+ with path.open("rb") as stream:
576
+ values = tomllib.load(stream)
577
+ unknown = values.keys() - {"data", "fonts", "generation"}
578
+ if unknown:
579
+ raise ValueError(f"Unknown config sections: {sorted(unknown)}")
580
+ sections = {}
581
+ for name, cls in (("data", Data), ("fonts", Fonts), ("generation", Generation)):
582
+ section = values.get(name, {})
583
+ if not isinstance(section, dict):
584
+ raise ValueError(f"{name} must be a TOML table")
585
+ unknown = section.keys() - {f.name for f in fields(cls)}
586
+ if unknown:
587
+ raise ValueError(f"Unknown {name} settings: {sorted(unknown)}")
588
+ sections[name] = cls(**section)
589
+ return Config(root=path.parent, **sections).validate()