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/static/app.js ADDED
@@ -0,0 +1,1759 @@
1
+ "use strict";
2
+
3
+ const $ = (id) => document.getElementById(id);
4
+ const icons = {
5
+ "arrow-right": '<path d="M4 12h16m-6-6 6 6-6 6"/>',
6
+ chevron: '<path d="m7 10 5 5 5-5"/>',
7
+ plus: '<path d="M12 5v14M5 12h14"/>',
8
+ refresh:
9
+ '<path d="M20 7v5h-5M4 17v-5h5"/><path d="M6.2 7a7 7 0 0 1 11.5-1L20 9M4 15l2.3 3A7 7 0 0 0 17.8 17"/>',
10
+ download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
11
+ folder: '<path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10H3V7Z"/>',
12
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5"/>',
13
+ moon: '<path d="M20 14A8.5 8.5 0 0 1 10 4a8.5 8.5 0 1 0 10 10Z"/>',
14
+ };
15
+
16
+ function icon(name) {
17
+ return `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name] || ""}</svg>`;
18
+ }
19
+
20
+ function mountIcons(root = document) {
21
+ root.querySelectorAll("[data-icon]").forEach((node) => {
22
+ node.innerHTML = icon(node.dataset.icon);
23
+ });
24
+ }
25
+
26
+ mountIcons();
27
+
28
+ const state = {
29
+ info: null,
30
+ job: null,
31
+ runningId: null,
32
+ pollTimer: null,
33
+ previewTimer: null,
34
+ previewController: null,
35
+ previewSequence: 0,
36
+ previewIteration: Math.floor(Math.random() * 32),
37
+ galleryOffset: 0,
38
+ gallerySequence: 0,
39
+ galleryController: null,
40
+ galleryJob: null,
41
+ jobs: [],
42
+ toastTimer: null,
43
+ nameEdited: false,
44
+ samplesEdited: false,
45
+ sourceMode: "corpus",
46
+ upload: null,
47
+ uploadController: null,
48
+ uploadSequence: 0,
49
+ uploadPending: false,
50
+ downloadPending: false,
51
+ languageShares: {},
52
+ lastShareEdited: null,
53
+ fileLanguage: null,
54
+ style: "balanced",
55
+ custom: null,
56
+ folder: null,
57
+ fontCatalog: [],
58
+ fontChoice: null,
59
+ fontPending: false,
60
+ saveTargets: new Map(),
61
+ saving: false,
62
+ };
63
+ const terminal = new Set(["completed", "failed", "cancelled"]);
64
+ const PREVIEW_EXAMPLES = 3;
65
+ const GALLERY_PAGE = 6;
66
+ const LANGUAGE_TAGS = { sorani: "ckb", kurmanji: "kmr", ar: "ar", en: "en" };
67
+ // Chrome and Edge can write into a folder the user picks; other browsers download a ZIP.
68
+ const canChooseFolder = typeof window.showDirectoryPicker === "function";
69
+
70
+ async function api(path, options = {}) {
71
+ const response = await fetch(path, {
72
+ ...options,
73
+ headers: { "Content-Type": "application/json", ...options.headers },
74
+ });
75
+ if (!response.ok) throw new Error(await errorMessage(response));
76
+ return response.json();
77
+ }
78
+
79
+ async function errorMessage(response) {
80
+ let message = `Request failed (${response.status}). Please try again.`;
81
+ try {
82
+ const body = await response.json();
83
+ if (typeof body.detail === "string") message = body.detail;
84
+ else if (Array.isArray(body.detail)) {
85
+ message = body.detail
86
+ .map(
87
+ (error) =>
88
+ `${error.loc?.slice(1).join(".") || "Settings"}: ${error.msg}`,
89
+ )
90
+ .join(". ");
91
+ } else if (body.detail && typeof body.detail.message === "string") {
92
+ message = body.detail.message;
93
+ } else if (body.error) message = String(body.error);
94
+ } catch {
95
+ /* A failed request may return a non-JSON response. */
96
+ }
97
+ return message;
98
+ }
99
+
100
+ function post(path, body, options = {}) {
101
+ return api(path, {
102
+ method: "POST",
103
+ body: JSON.stringify(body),
104
+ ...options,
105
+ });
106
+ }
107
+
108
+ function selectedLanguages() {
109
+ return [...document.querySelectorAll('input[name="language"]:checked')]
110
+ .map((input) => LANGUAGE_TAGS[input.value])
111
+ .filter(Boolean);
112
+ }
113
+
114
+ function selectedLanguage() {
115
+ return languageParameter(selectedLanguages());
116
+ }
117
+
118
+ // The legacy single-value form of a language selection.
119
+ function languageParameter(languages) {
120
+ if (
121
+ languages.length === 2 &&
122
+ languages.includes("ckb") &&
123
+ languages.includes("kmr")
124
+ )
125
+ return "both";
126
+ return (
127
+ { ckb: "sorani", kmr: "kurmanji" }[languages[0]] || languages[0] || ""
128
+ );
129
+ }
130
+
131
+ function selectedSource() {
132
+ return (
133
+ document.querySelector('input[name="source"]:checked')?.value ||
134
+ "corpus"
135
+ );
136
+ }
137
+
138
+ function selectedFormat() {
139
+ return (
140
+ document.querySelector('input[name="download-format"]:checked')
141
+ ?.value || "dataset"
142
+ );
143
+ }
144
+
145
+ // A TXT file has no language column, so with several languages selected the
146
+ // user says which one it is. The app never guesses a language from the script.
147
+ function needsFileLanguage() {
148
+ return (
149
+ selectedSource() === "upload" &&
150
+ /\.txt$/i.test($("corpus-file").files?.[0]?.name || "") &&
151
+ selectedLanguages().length > 1
152
+ );
153
+ }
154
+
155
+ // The languages the dataset will actually contain.
156
+ function datasetLanguages() {
157
+ if (needsFileLanguage())
158
+ return state.fileLanguage ? [state.fileLanguage] : [];
159
+ return selectedLanguages();
160
+ }
161
+
162
+ function languageError() {
163
+ if (!selectedLanguages().length) return "Select at least one language.";
164
+ if (needsFileLanguage() && !state.fileLanguage)
165
+ return "Choose the language of your text file.";
166
+ return "";
167
+ }
168
+
169
+ function languageName(language) {
170
+ const known = {
171
+ sorani: "Sorani",
172
+ ckb: "Sorani",
173
+ kurmanji: "Kurmanji",
174
+ kmr: "Kurmanji",
175
+ both: "Sorani + Kurmanji",
176
+ ar: "Arabic",
177
+ en: "English",
178
+ };
179
+ if (known[language]) return known[language];
180
+ try {
181
+ return (
182
+ new Intl.DisplayNames(["en"], { type: "language" }).of(language) ||
183
+ language
184
+ );
185
+ } catch {
186
+ return language;
187
+ }
188
+ }
189
+
190
+ function selectionName(languages, fallback) {
191
+ return languages?.length
192
+ ? languages.map(languageName).join(" + ")
193
+ : languageName(fallback);
194
+ }
195
+
196
+ // Whole numbers that add up to exactly total, in proportion to the weights
197
+ // (equal when every weight is zero).
198
+ function divideShares(names, weights, total = 100) {
199
+ if (!names.length) return {};
200
+ const values = names.map((name) => Math.max(0, weights[name] ?? 0));
201
+ const sum = values.reduce((a, b) => a + b, 0);
202
+ const exact = values.map((value) =>
203
+ sum ? (total * value) / sum : total / names.length,
204
+ );
205
+ const shares = exact.map(Math.floor);
206
+ const order = names
207
+ .map((_, index) => index)
208
+ .sort((a, b) => exact[b] - shares[b] - (exact[a] - shares[a]) || a - b);
209
+ const remainder = total - shares.reduce((a, b) => a + b, 0);
210
+ for (const index of order.slice(0, remainder)) shares[index]++;
211
+ return Object.fromEntries(
212
+ names.map((name, index) => [name, shares[index]]),
213
+ );
214
+ }
215
+
216
+ // Set one language's share. The share edited just before stays put when it
217
+ // can, and the remaining languages keep their proportions in what is left.
218
+ function rebalanceShares(shares, names, changed, value, previous = null) {
219
+ const fixed = Math.max(0, Math.min(100, Math.round(value) || 0));
220
+ const others = names.filter((name) => name !== changed);
221
+ if (!others.length) return { [changed]: 100 };
222
+ const kept =
223
+ previous !== changed &&
224
+ others.includes(previous) &&
225
+ others.length > 1 &&
226
+ shares[previous] <= 100 - fixed
227
+ ? previous
228
+ : null;
229
+ const adjusted = others.filter((name) => name !== kept);
230
+ const rest = 100 - fixed - (kept ? shares[kept] : 0);
231
+ return {
232
+ ...divideShares(adjusted, shares, rest),
233
+ ...(kept ? { [kept]: shares[kept] } : {}),
234
+ [changed]: fixed,
235
+ };
236
+ }
237
+
238
+ function updateShareCounts() {
239
+ const total = Math.max(0, Number($("samples").value) || 0);
240
+ for (const tag of datasetLanguages()) {
241
+ const count = document.querySelector(`[data-share-count="${tag}"]`);
242
+ if (count)
243
+ count.textContent = `≈ ${Math.round((total * (state.languageShares[tag] || 0)) / 100).toLocaleString()} images`;
244
+ }
245
+ }
246
+
247
+ function renderFileLanguage() {
248
+ const tags = selectedLanguages();
249
+ if (!tags.includes(state.fileLanguage)) state.fileLanguage = null;
250
+ const show = needsFileLanguage();
251
+ $("file-language-field").hidden = !show;
252
+ const options = $("file-language-options");
253
+ options.replaceChildren();
254
+ if (!show) return;
255
+ for (const tag of tags) {
256
+ const label = document.createElement("label");
257
+ label.className = "chip";
258
+ const input = document.createElement("input");
259
+ input.type = "radio";
260
+ input.name = "file-language";
261
+ input.value = tag;
262
+ input.checked = tag === state.fileLanguage;
263
+ const text = document.createElement("span");
264
+ text.textContent = languageName(tag);
265
+ label.append(input, text);
266
+ options.append(label);
267
+ }
268
+ }
269
+
270
+ function renderLanguageShares() {
271
+ const tags = datasetLanguages();
272
+ const known = Object.keys(state.languageShares);
273
+ if (
274
+ known.length !== tags.length ||
275
+ tags.some((tag) => !known.includes(tag))
276
+ ) {
277
+ state.languageShares = divideShares(tags, {});
278
+ state.lastShareEdited = null;
279
+ }
280
+ $("language-shares-field").hidden = tags.length < 2;
281
+ const list = $("language-shares");
282
+ list.replaceChildren();
283
+ if (tags.length < 2) return;
284
+ for (const tag of tags) {
285
+ const row = document.createElement("div");
286
+ row.className = "share-row";
287
+ const name = document.createElement("label");
288
+ name.className = "share-name";
289
+ name.htmlFor = `share-${tag}`;
290
+ name.textContent = languageName(tag);
291
+ const input = document.createElement("input");
292
+ input.type = "number";
293
+ input.id = `share-${tag}`;
294
+ input.min = "0";
295
+ input.max = "100";
296
+ input.step = "1";
297
+ input.inputMode = "numeric";
298
+ input.value = String(state.languageShares[tag]);
299
+ input.dataset.share = tag;
300
+ input.addEventListener("input", () => {
301
+ if (!Number.isFinite(input.valueAsNumber)) return;
302
+ state.languageShares = rebalanceShares(
303
+ state.languageShares,
304
+ tags,
305
+ tag,
306
+ input.valueAsNumber,
307
+ state.lastShareEdited,
308
+ );
309
+ for (const other of list.querySelectorAll("[data-share]"))
310
+ if (other !== input)
311
+ other.value = String(
312
+ state.languageShares[other.dataset.share],
313
+ );
314
+ updateShareCounts();
315
+ });
316
+ input.addEventListener("change", () => {
317
+ input.value = String(state.languageShares[tag]);
318
+ state.lastShareEdited = tag;
319
+ });
320
+ const percent = document.createElement("span");
321
+ percent.className = "share-percent";
322
+ percent.textContent = "%";
323
+ const count = document.createElement("span");
324
+ count.className = "share-count";
325
+ count.dataset.shareCount = tag;
326
+ row.append(name, input, percent, count);
327
+ list.append(row);
328
+ }
329
+ updateShareCounts();
330
+ }
331
+
332
+ // A style is a server preset; Customize overrides a few of its fields.
333
+ function appearanceSettings() {
334
+ const base = {
335
+ ...(state.info?.image_presets?.[state.style]?.settings || {}),
336
+ };
337
+ const custom = state.custom;
338
+ if (!custom) return base;
339
+ return {
340
+ ...base,
341
+ augmentation: custom.augmentation,
342
+ background_presets: [...custom.backgrounds],
343
+ background_weights: {},
344
+ ...custom.layers,
345
+ rotation: custom.rotation,
346
+ blur: custom.blur,
347
+ contrast: custom.contrast,
348
+ };
349
+ }
350
+
351
+ // Each effect is Auto (the background's own amount), off, or a fixed strength.
352
+ const EFFECT_LEVELS = [
353
+ ["auto", "Auto"],
354
+ ["0", "Off"],
355
+ ["0.2", "Light"],
356
+ ["0.6", "Strong"],
357
+ ];
358
+ // Sliders for the variation limits; each image draws a random amount up to them.
359
+ const GEOMETRY = {
360
+ rotation: { id: "tilt-amount", show: (value) => `${value}°` },
361
+ blur: { id: "blur-amount", show: (value) => `${value} px` },
362
+ contrast: {
363
+ id: "contrast-amount",
364
+ show: (value) => `${Math.round(value * 100)}%`,
365
+ scale: 100,
366
+ },
367
+ };
368
+
369
+ function readGeometry(name) {
370
+ const item = GEOMETRY[name];
371
+ return Number($(item.id).value) / (item.scale || 1);
372
+ }
373
+
374
+ function showGeometry(name, value) {
375
+ const item = GEOMETRY[name];
376
+ $(item.id).value = String(value * (item.scale || 1));
377
+ $(`${item.id}-value`).textContent = item.show(value);
378
+ }
379
+
380
+ function closestLevel(levels, value) {
381
+ if (value == null) return "auto";
382
+ return levels
383
+ .map(([level]) => level)
384
+ .filter((level) => level !== "auto")
385
+ .reduce((best, level) =>
386
+ Math.abs(level - value) < Math.abs(best - value) ? level : best,
387
+ );
388
+ }
389
+
390
+ function readLevel(name) {
391
+ const value = document.querySelector(
392
+ `input[name="effect-${name}"]:checked`,
393
+ )?.value;
394
+ return value === undefined || value === "auto" ? null : Number(value);
395
+ }
396
+
397
+ function effectRow(container, name, label, description, levels) {
398
+ const row = document.createElement("div");
399
+ row.className = "effect-row";
400
+ const text = document.createElement("span");
401
+ text.className = "effect-text";
402
+ const title = document.createElement("strong");
403
+ title.textContent = label;
404
+ const detail = document.createElement("small");
405
+ detail.textContent = description;
406
+ text.append(title, detail);
407
+ const group = document.createElement("div");
408
+ group.className = "segmented";
409
+ group.setAttribute("role", "radiogroup");
410
+ group.setAttribute("aria-label", label);
411
+ for (const [value, caption] of levels) {
412
+ const option = document.createElement("label");
413
+ const input = document.createElement("input");
414
+ input.type = "radio";
415
+ input.name = `effect-${name}`;
416
+ input.value = value;
417
+ const span = document.createElement("span");
418
+ span.textContent = caption;
419
+ option.append(input, span);
420
+ group.append(option);
421
+ }
422
+ row.append(text, group);
423
+ container.append(row);
424
+ }
425
+
426
+ function mountEffectOptions(layers) {
427
+ $("effect-options").replaceChildren();
428
+ for (const layer of layers)
429
+ effectRow(
430
+ $("effect-options"),
431
+ layer.id,
432
+ layer.label,
433
+ layer.description,
434
+ EFFECT_LEVELS,
435
+ );
436
+ }
437
+
438
+ function effectLayers() {
439
+ return (state.info?.document_layers || []).map((layer) => layer.id);
440
+ }
441
+
442
+ function currentSettings() {
443
+ const source = selectedSource();
444
+ const languages = datasetLanguages();
445
+ return {
446
+ name: $("dataset-name").value.trim(),
447
+ language: languageParameter(languages),
448
+ languages,
449
+ language_weights:
450
+ languages.length > 1 ? { ...state.languageShares } : {},
451
+ source,
452
+ upload_id: source === "upload" ? state.upload?.id || null : null,
453
+ samples: Number($("samples").value),
454
+ font_size: Number($("text-size").value),
455
+ fonts: chosenFontIds(),
456
+ ...appearanceSettings(),
457
+ // One dataset: every image is in the same set, with no held-out split.
458
+ train_ratio: 1,
459
+ validation_ratio: 0,
460
+ };
461
+ }
462
+
463
+ function randomSeed() {
464
+ const values = new Uint32Array(1);
465
+ crypto.getRandomValues(values);
466
+ return values[0];
467
+ }
468
+
469
+ function mountBackgroundOptions(catalog) {
470
+ const container = $("background-options");
471
+ container.replaceChildren();
472
+ for (const [name, preset] of Object.entries(catalog)) {
473
+ const label = document.createElement("label");
474
+ label.className = "chip";
475
+ label.title = preset.description || "";
476
+ const input = document.createElement("input");
477
+ input.type = "checkbox";
478
+ input.name = "background";
479
+ input.value = name;
480
+ const text = document.createElement("span");
481
+ text.textContent = preset.label;
482
+ label.append(input, text);
483
+ container.append(label);
484
+ }
485
+ }
486
+
487
+ function showStyleControls() {
488
+ const settings = appearanceSettings();
489
+ const amount = Math.round((settings.augmentation || 0) * 100);
490
+ $("effects-amount").value = String(amount);
491
+ $("effects-amount-value").textContent = `${amount}%`;
492
+ const selected = settings.background_presets || [];
493
+ for (const input of document.querySelectorAll('input[name="background"]'))
494
+ input.checked = selected.includes(input.value);
495
+ for (const name of effectLayers()) {
496
+ const value = closestLevel(EFFECT_LEVELS, settings[name] ?? null);
497
+ const input = document.querySelector(
498
+ `input[name="effect-${name}"][value="${value}"]`,
499
+ );
500
+ if (input) input.checked = true;
501
+ }
502
+ for (const name of Object.keys(GEOMETRY))
503
+ showGeometry(name, Number(settings[name] ?? 0));
504
+ for (const id of ["backgrounds-field", "effects-field", "geometry-field"])
505
+ $(id).disabled = amount === 0;
506
+ for (const input of document.querySelectorAll('input[name="style"]'))
507
+ input.checked = !state.custom && input.value === state.style;
508
+ $("style-status").hidden = !state.custom;
509
+ }
510
+
511
+ function readCustomControls() {
512
+ const augmentation = Number($("effects-amount").value) / 100;
513
+ let backgrounds = [
514
+ ...document.querySelectorAll('input[name="background"]:checked'),
515
+ ].map((input) => input.value);
516
+ // Images with effects need at least one background to draw on.
517
+ if (augmentation > 0 && !backgrounds.length)
518
+ backgrounds = state.info?.image_presets?.balanced?.settings
519
+ .background_presets || [
520
+ document.querySelector('input[name="background"]')?.value,
521
+ ];
522
+ state.custom = {
523
+ augmentation,
524
+ backgrounds: backgrounds.filter(Boolean),
525
+ layers: Object.fromEntries(
526
+ effectLayers().map((name) => [name, readLevel(name)]),
527
+ ),
528
+ rotation: readGeometry("rotation"),
529
+ blur: readGeometry("blur"),
530
+ contrast: readGeometry("contrast"),
531
+ };
532
+ showStyleControls();
533
+ schedulePreview();
534
+ }
535
+
536
+ // null means every font; otherwise the set of chosen font IDs.
537
+ function chosenFontIds() {
538
+ return state.fontChoice ? [...state.fontChoice] : [];
539
+ }
540
+
541
+ function chosenFonts() {
542
+ return state.fontCatalog.filter(
543
+ (font) => !state.fontChoice || state.fontChoice.has(font.id),
544
+ );
545
+ }
546
+
547
+ function fontError() {
548
+ if (state.fontCatalog.length && !chosenFonts().length)
549
+ return "Choose at least one font.";
550
+ return "";
551
+ }
552
+
553
+ function fontWarning() {
554
+ const chosen = chosenFonts();
555
+ const missing = datasetLanguages().filter(
556
+ (tag) => !chosen.some((font) => font.languages.includes(tag)),
557
+ );
558
+ return chosen.length && missing.length
559
+ ? `None of the chosen fonts can draw all of ${missing.map(languageName).join(" or ")}. Images in that language may fail.`
560
+ : "";
561
+ }
562
+
563
+ function updateFontSummary() {
564
+ const total = state.fontCatalog.length;
565
+ const count = chosenFonts().length;
566
+ $("fonts-summary").textContent = state.fontChoice
567
+ ? `${count} of ${total}`
568
+ : `All ${total}`;
569
+ const message = fontError() || fontWarning();
570
+ $("font-error").textContent = message;
571
+ $("font-error").hidden = !message;
572
+ }
573
+
574
+ function renderFonts() {
575
+ const list = $("font-list");
576
+ list.replaceChildren();
577
+ for (const font of state.fontCatalog) {
578
+ const row = document.createElement("div");
579
+ row.className = "font-row";
580
+ const label = document.createElement("label");
581
+ label.title = font.file;
582
+ const input = document.createElement("input");
583
+ input.type = "checkbox";
584
+ input.value = font.id;
585
+ input.checked = !state.fontChoice || state.fontChoice.has(font.id);
586
+ const name = document.createElement("span");
587
+ name.className = "font-name";
588
+ name.textContent = font.name;
589
+ const tags = document.createElement("span");
590
+ tags.className = "font-tags";
591
+ tags.textContent = font.languages.map(languageName).join(" · ");
592
+ label.append(input, name, tags);
593
+ row.append(label);
594
+ if (font.user) {
595
+ const remove = document.createElement("button");
596
+ remove.type = "button";
597
+ remove.className = "text-button";
598
+ remove.textContent = "Remove";
599
+ remove.setAttribute("aria-label", `Remove ${font.name}`);
600
+ remove.addEventListener("click", () => removeFont(font));
601
+ row.append(remove);
602
+ }
603
+ list.append(row);
604
+ }
605
+ updateFontSummary();
606
+ }
607
+
608
+ async function loadFonts() {
609
+ const result = await api("/api/fonts");
610
+ state.fontCatalog = result.fonts;
611
+ if (state.fontChoice?.size) {
612
+ const known = new Set(state.fontCatalog.map((font) => font.id));
613
+ const kept = [...state.fontChoice].filter((id) => known.has(id));
614
+ // If every chosen font was removed, go back to using all fonts.
615
+ state.fontChoice = kept.length ? new Set(kept) : null;
616
+ }
617
+ renderFonts();
618
+ }
619
+
620
+ function setFontChoice(choice) {
621
+ state.fontChoice =
622
+ choice && choice.size < state.fontCatalog.length ? choice : null;
623
+ renderFonts();
624
+ schedulePreview();
625
+ setBusy();
626
+ }
627
+
628
+ async function addFonts() {
629
+ const files = [...$("font-file").files];
630
+ $("font-file").value = "";
631
+ if (!files.length) return;
632
+ state.fontPending = true;
633
+ setBusy();
634
+ const added = [];
635
+ const known = [];
636
+ try {
637
+ for (const file of files) {
638
+ $("font-status").textContent = `Adding ${file.name}…`;
639
+ const result = await api(
640
+ `/api/fonts?filename=${encodeURIComponent(file.name)}`,
641
+ {
642
+ method: "POST",
643
+ body: file,
644
+ headers: { "Content-Type": "application/octet-stream" },
645
+ },
646
+ );
647
+ (result.added ? added : known).push(...result.fonts);
648
+ }
649
+ await loadFonts();
650
+ if (state.fontChoice)
651
+ setFontChoice(
652
+ new Set([
653
+ ...state.fontChoice,
654
+ ...[...added, ...known].map((font) => font.id),
655
+ ]),
656
+ );
657
+ $("font-status").textContent = [
658
+ added.length
659
+ ? `Added ${added.map((font) => font.name).join(", ")}.`
660
+ : "",
661
+ known.length ? "Some files were already available." : "",
662
+ ]
663
+ .filter(Boolean)
664
+ .join(" ");
665
+ schedulePreview();
666
+ } catch (error) {
667
+ $("font-status").textContent = error.message;
668
+ } finally {
669
+ state.fontPending = false;
670
+ setBusy();
671
+ }
672
+ }
673
+
674
+ async function removeFont(font) {
675
+ try {
676
+ await api(`/api/fonts/${encodeURIComponent(font.id.split(":")[0])}`, {
677
+ method: "DELETE",
678
+ });
679
+ $("font-status").textContent = `Removed ${font.name}.`;
680
+ await loadFonts();
681
+ schedulePreview();
682
+ setBusy();
683
+ } catch (error) {
684
+ $("font-status").textContent = error.message;
685
+ }
686
+ }
687
+
688
+ function updateThemeButton() {
689
+ const dark = document.documentElement.dataset.theme !== "light";
690
+ $("theme-label").textContent = dark ? "Light mode" : "Dark mode";
691
+ $("theme-toggle").setAttribute(
692
+ "aria-label",
693
+ `Switch to ${dark ? "light" : "dark"} mode`,
694
+ );
695
+ $("theme-toggle").querySelector("[data-icon]").innerHTML = icon(
696
+ dark ? "sun" : "moon",
697
+ );
698
+ }
699
+
700
+ function toast(message) {
701
+ clearTimeout(state.toastTimer);
702
+ $("toast").textContent = message;
703
+ $("toast").hidden = false;
704
+ state.toastTimer = setTimeout(() => {
705
+ $("toast").hidden = true;
706
+ }, 3500);
707
+ }
708
+
709
+ function setError(message = "") {
710
+ $("form-error").textContent = message;
711
+ $("form-error").hidden = !message;
712
+ }
713
+
714
+ function suggestName() {
715
+ if (state.nameEdited) return;
716
+ const now = new Date();
717
+ const pad = (value) => String(value).padStart(2, "0");
718
+ const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
719
+ const time = `${pad(now.getHours())}${pad(now.getMinutes())}`;
720
+ const chosen = datasetLanguages();
721
+ const languages = chosen.length ? chosen : selectedLanguages();
722
+ const prefix = languages.some((tag) => ["ckb", "kmr"].includes(tag))
723
+ ? languages.length === 1
724
+ ? languageParameter(languages)
725
+ : "kurdish"
726
+ : languages.join("-") || "dataset";
727
+ const base = `${prefix.slice(0, 32)}-${date}-${time}`;
728
+ let name = base;
729
+ let suffix = 2;
730
+ while (state.jobs.some((job) => job.name === name))
731
+ name = `${base}-${suffix++}`;
732
+ $("dataset-name").value = name;
733
+ updateFolder();
734
+ }
735
+
736
+ function updateFolder() {
737
+ $("choose-folder").hidden = !canChooseFolder;
738
+ $("folder-name").textContent = state.folder
739
+ ? state.folder.name
740
+ : canChooseFolder
741
+ ? "No folder chosen"
742
+ : "Your browser's downloads";
743
+ const name = $("dataset-name").value.trim() || "dataset";
744
+ $("folder-status").textContent = !canChooseFolder
745
+ ? "This browser cannot save into a folder; use Chrome or Edge for that. You can download a ZIP when the dataset is ready."
746
+ : state.folder
747
+ ? `Saved as ${state.folder.name}/${name} when it is ready.`
748
+ : "Choose a folder on your computer. The dataset is saved there when it is ready.";
749
+ }
750
+
751
+ async function chooseFolder() {
752
+ try {
753
+ state.folder = await window.showDirectoryPicker({
754
+ id: "synthocr-datasets",
755
+ mode: "readwrite",
756
+ });
757
+ updateFolder();
758
+ return true;
759
+ } catch (error) {
760
+ if (error.name !== "AbortError") setError(error.message);
761
+ return false;
762
+ }
763
+ }
764
+
765
+ function updateSource() {
766
+ const source = selectedSource();
767
+ if (state.sourceMode === "upload" && source !== "upload") resetUpload();
768
+ state.sourceMode = source;
769
+ $("upload-field").hidden = source !== "upload";
770
+ const error = selectedLanguages().length ? "" : languageError();
771
+ $("language-help").textContent = error;
772
+ $("language-help").hidden = !error;
773
+ setError();
774
+ suggestName();
775
+ renderFileLanguage();
776
+ renderLanguageShares();
777
+ if (state.fontCatalog.length) updateFontSummary();
778
+ schedulePreview();
779
+ setBusy();
780
+ }
781
+
782
+ async function discardUpload(upload) {
783
+ if (upload && !upload.consumed) {
784
+ try {
785
+ await api(`/api/uploads/${encodeURIComponent(upload.id)}`, {
786
+ method: "DELETE",
787
+ });
788
+ } catch {
789
+ /* Unused uploads also expire automatically. */
790
+ }
791
+ }
792
+ }
793
+
794
+ function resetUpload() {
795
+ state.uploadController?.abort();
796
+ state.uploadSequence++;
797
+ state.uploadPending = false;
798
+ const previous = state.upload;
799
+ state.upload = null;
800
+ discardUpload(previous);
801
+ }
802
+
803
+ async function uploadSelectedFile() {
804
+ state.uploadController?.abort();
805
+ const sequence = ++state.uploadSequence;
806
+ const previous = state.upload;
807
+ state.upload = null;
808
+ state.uploadPending = true;
809
+ setBusy();
810
+ await discardUpload(previous);
811
+ if (sequence !== state.uploadSequence) return;
812
+ const file = $("corpus-file").files[0];
813
+ const status = $("upload-status");
814
+ status.hidden = false;
815
+ state.uploadPending = false;
816
+ if (!file) {
817
+ status.textContent = "Choose a file to upload.";
818
+ setBusy();
819
+ return;
820
+ }
821
+ const limit = state.info?.upload_limit_bytes || 50 * 1024 * 1024;
822
+ const error = languageError();
823
+ if (error) {
824
+ status.textContent = error;
825
+ setBusy();
826
+ return;
827
+ }
828
+ if (
829
+ !/\.(txt|jsonl|csv)$/i.test(file.name) ||
830
+ file.size > limit ||
831
+ !file.size
832
+ ) {
833
+ status.textContent = `Choose a UTF-8 TXT, CSV or JSONL file up to ${Math.round(limit / 1048576)} MB.`;
834
+ setBusy();
835
+ return;
836
+ }
837
+ const controller = new AbortController();
838
+ state.uploadController = controller;
839
+ state.uploadPending = true;
840
+ status.textContent = `Uploading ${file.name}…`;
841
+ setBusy();
842
+ try {
843
+ const result = await api(
844
+ `/api/uploads?filename=${encodeURIComponent(file.name)}&language=${encodeURIComponent(languageParameter(datasetLanguages()))}${datasetLanguages()
845
+ .map((tag) => `&languages=${encodeURIComponent(tag)}`)
846
+ .join("")}`,
847
+ {
848
+ method: "POST",
849
+ body: file,
850
+ headers: { "Content-Type": "application/octet-stream" },
851
+ signal: controller.signal,
852
+ },
853
+ );
854
+ if (
855
+ sequence !== state.uploadSequence ||
856
+ selectedSource() !== "upload"
857
+ ) {
858
+ await discardUpload(result);
859
+ return;
860
+ }
861
+ state.upload = result;
862
+ const fileLanguage = /\.txt$/i.test(file.name)
863
+ ? `, all ${languageName(datasetLanguages()[0])}`
864
+ : "";
865
+ status.textContent = `${result.filename}: ${Number(result.records).toLocaleString()} texts found${fileLanguage}.`;
866
+ if (!state.samplesEdited) {
867
+ $("samples").value = Math.min(1000, Number(result.records) || 1);
868
+ updateShareCounts();
869
+ }
870
+ schedulePreview();
871
+ } catch (error) {
872
+ if (error.name !== "AbortError" && sequence === state.uploadSequence)
873
+ status.textContent = error.message;
874
+ } finally {
875
+ if (sequence === state.uploadSequence) {
876
+ state.uploadPending = false;
877
+ setBusy();
878
+ }
879
+ }
880
+ }
881
+
882
+ function schedulePreview() {
883
+ clearTimeout(state.previewTimer);
884
+ state.previewController?.abort();
885
+ state.previewSequence++;
886
+ $("preview-error").hidden = true;
887
+ $("preview-loading").hidden = false;
888
+ $("preview").setAttribute("aria-busy", "true");
889
+ if (!document.hidden) state.previewTimer = setTimeout(loadPreview, 300);
890
+ }
891
+
892
+ function showPreviewError(message) {
893
+ $("preview-loading").hidden = true;
894
+ $("preview-error").textContent = message;
895
+ $("preview-error").hidden = false;
896
+ $("preview-grid").replaceChildren();
897
+ $("preview-text").textContent = "—";
898
+ $("preview").setAttribute("aria-busy", "false");
899
+ }
900
+
901
+ function checkedPreviewImage(image) {
902
+ if (
903
+ typeof image !== "string" ||
904
+ !image.startsWith("data:image/png;base64,")
905
+ )
906
+ throw new Error("The preview returned an unexpected image.");
907
+ return image;
908
+ }
909
+
910
+ // Show a few of the style's backgrounds, rotating as the user asks for more.
911
+ function previewBackgrounds(settings) {
912
+ const all = settings.background_presets || [];
913
+ if (all.length <= PREVIEW_EXAMPLES) return all;
914
+ const start = (state.previewIteration * PREVIEW_EXAMPLES) % all.length;
915
+ return Array.from(
916
+ { length: PREVIEW_EXAMPLES },
917
+ (_, index) => all[(start + index) % all.length],
918
+ );
919
+ }
920
+
921
+ function previewTile(image, caption, alt) {
922
+ const figure = document.createElement("figure");
923
+ figure.className = "preview-tile";
924
+ const wrap = document.createElement("div");
925
+ wrap.className = "preview-image";
926
+ const img = document.createElement("img");
927
+ img.src = checkedPreviewImage(image);
928
+ img.alt = alt;
929
+ wrap.append(img);
930
+ const label = document.createElement("figcaption");
931
+ label.textContent = caption;
932
+ figure.append(wrap, label);
933
+ return figure;
934
+ }
935
+
936
+ async function loadPreview() {
937
+ if (!state.info) return;
938
+ state.previewController?.abort();
939
+ const controller = new AbortController();
940
+ state.previewController = controller;
941
+ const sequence = ++state.previewSequence;
942
+ const settings = currentSettings();
943
+ let language = settings.language;
944
+ let text = "";
945
+ const unavailable =
946
+ languageError() ||
947
+ fontError() ||
948
+ (settings.source === "upload" && !state.upload
949
+ ? "Upload a file to preview its text."
950
+ : "");
951
+ if (unavailable) {
952
+ showPreviewError(unavailable);
953
+ return;
954
+ }
955
+ if (settings.source === "upload") {
956
+ text = state.upload.preview_text;
957
+ language = state.upload.preview_language || language;
958
+ }
959
+ const backgrounds = previewBackgrounds(settings);
960
+ const augmented = settings.augmentation > 0 && backgrounds.length > 0;
961
+ $("refresh-preview").disabled = true;
962
+ try {
963
+ const corpus = settings.source === "corpus";
964
+ const result = await post(
965
+ "/api/preview",
966
+ {
967
+ ...appearanceSettings(),
968
+ text,
969
+ language,
970
+ languages: corpus
971
+ ? settings.languages
972
+ : [
973
+ { sorani: "ckb", kurmanji: "kmr" }[language] ||
974
+ language,
975
+ ],
976
+ source: corpus ? "corpus" : "demo",
977
+ index: state.previewIteration % 32,
978
+ augmentation: augmented ? 1 : 0,
979
+ background_presets: augmented ? backgrounds : [],
980
+ background_weights: {},
981
+ seed: 42 + state.previewIteration,
982
+ font_size: settings.font_size,
983
+ fonts: settings.fonts,
984
+ compare: false,
985
+ },
986
+ { signal: controller.signal },
987
+ );
988
+ if (sequence !== state.previewSequence) return;
989
+ const grid = $("preview-grid");
990
+ const tiles = [];
991
+ // A style that leaves some images clean shows that clean image too.
992
+ if (!augmented || settings.augmentation < 1)
993
+ tiles.push(
994
+ previewTile(
995
+ (result.before || result).image,
996
+ "Clean",
997
+ `Clean example: ${result.text}`,
998
+ ),
999
+ );
1000
+ if (augmented)
1001
+ for (const variant of result.variants || [])
1002
+ tiles.push(
1003
+ previewTile(
1004
+ variant.image,
1005
+ variant.name,
1006
+ `${variant.name} example: ${result.text}`,
1007
+ ),
1008
+ );
1009
+ grid.replaceChildren(...tiles);
1010
+ grid.dataset.count = String(tiles.length);
1011
+ // Long lines become very wide images; give each one the full width.
1012
+ const before = result.before || result;
1013
+ grid.classList.toggle("wide", before.width > 6 * before.height);
1014
+ language = result.source_language || language;
1015
+ $("preview-text").textContent = result.text;
1016
+ $("preview-text").lang =
1017
+ { sorani: "ckb", kurmanji: "kmr" }[language] || language;
1018
+ $("preview-loading").hidden = true;
1019
+ $("preview").setAttribute("aria-busy", "false");
1020
+ } catch (error) {
1021
+ if (error.name === "AbortError" || sequence !== state.previewSequence)
1022
+ return;
1023
+ showPreviewError(
1024
+ error.message.includes("synthocr prepare")
1025
+ ? "The built-in text is prepared on the first run. You can generate now; the preview appears afterwards."
1026
+ : error.message,
1027
+ );
1028
+ } finally {
1029
+ if (sequence === state.previewSequence)
1030
+ $("refresh-preview").disabled = false;
1031
+ }
1032
+ }
1033
+
1034
+ function fontLabel(font) {
1035
+ if (typeof font === "string")
1036
+ return font
1037
+ .split(/[\\/]/)
1038
+ .pop()
1039
+ .replace(/#\d+$/, "")
1040
+ .replace(/\.[^.]+$/, "")
1041
+ .replace(/\[[^\]]*\]/g, "")
1042
+ .replace(/[_-]/g, " ")
1043
+ .replace(/([a-z])([A-Z])/g, "$1 $2");
1044
+ if (font && typeof font === "object")
1045
+ return font.family || font.name || "Matched automatically";
1046
+ return "Matched automatically";
1047
+ }
1048
+
1049
+ function setBusy() {
1050
+ const busy = Boolean(state.runningId);
1051
+ const unavailable =
1052
+ Boolean(languageError()) ||
1053
+ Boolean(fontError()) ||
1054
+ state.fontPending ||
1055
+ (selectedSource() === "upload" &&
1056
+ (state.uploadPending || !state.upload));
1057
+ $("generate-button").disabled = busy || !state.info || unavailable;
1058
+ $("generate-label").textContent = busy
1059
+ ? "Generating…"
1060
+ : state.uploadPending
1061
+ ? "Uploading…"
1062
+ : "Generate dataset";
1063
+ }
1064
+
1065
+ async function submitJob(event) {
1066
+ event.preventDefault();
1067
+ setError();
1068
+ if (languageError() || state.uploadPending || state.runningId) return;
1069
+ if (selectedSource() === "upload" && !state.upload) {
1070
+ setError("Upload a text file first.");
1071
+ return;
1072
+ }
1073
+ if (!$("generation-form").reportValidity()) return;
1074
+ // Ask for the folder first, while the click still counts as a user action.
1075
+ if (canChooseFolder && !state.folder && !(await chooseFolder())) {
1076
+ setError("Choose a folder to save the dataset in.");
1077
+ return;
1078
+ }
1079
+ $("generate-button").disabled = true;
1080
+ $("generate-label").textContent = "Starting…";
1081
+ try {
1082
+ if (selectedSource() === "upload" && state.upload?.consumed) {
1083
+ await uploadSelectedFile();
1084
+ if (!state.upload) return;
1085
+ }
1086
+ // A fresh seed per run, so two runs never repeat the same images. The
1087
+ // seed is saved with the dataset, so each run is still reproducible.
1088
+ const job = await post("/api/jobs", {
1089
+ ...currentSettings(),
1090
+ seed: randomSeed(),
1091
+ });
1092
+ if (selectedSource() === "upload" && state.upload)
1093
+ state.upload.consumed = true;
1094
+ if (state.folder)
1095
+ state.saveTargets.set(job.id, {
1096
+ folder: state.folder,
1097
+ format: selectedFormat(),
1098
+ saved: false,
1099
+ });
1100
+ state.runningId = job.id;
1101
+ state.galleryJob = null;
1102
+ showJob(job);
1103
+ await refreshJobs();
1104
+ state.nameEdited = false;
1105
+ suggestName();
1106
+ schedulePoll(400);
1107
+ $("generation-results").scrollIntoView({
1108
+ behavior: "smooth",
1109
+ block: "start",
1110
+ });
1111
+ } catch (error) {
1112
+ setError(error.message);
1113
+ } finally {
1114
+ setBusy();
1115
+ }
1116
+ }
1117
+
1118
+ function jobMessage(job) {
1119
+ if (job.status === "completed")
1120
+ return "Done. Every image and label was checked.";
1121
+ if (job.status === "failed") return "Generation stopped.";
1122
+ if (job.status === "cancelled") return "Cancelled.";
1123
+ if (job.status === "cancelling") return "Stopping…";
1124
+ return (
1125
+ {
1126
+ queued: "Waiting to start…",
1127
+ preparing: "Preparing text and fonts…",
1128
+ generating: "Making images…",
1129
+ verifying: "Checking images and labels…",
1130
+ }[job.status] || "Working…"
1131
+ );
1132
+ }
1133
+
1134
+ function safeDownload(id, format) {
1135
+ return `/api/jobs/${encodeURIComponent(id)}/download?format=${encodeURIComponent(format)}`;
1136
+ }
1137
+
1138
+ async function downloadDataset(format) {
1139
+ if (state.downloadPending || state.job?.status !== "completed") return;
1140
+ const job = state.job;
1141
+ state.downloadPending = true;
1142
+ $("download-button").disabled = true;
1143
+ $("download-error").hidden = true;
1144
+ $("download-status").textContent = "Preparing your download…";
1145
+ try {
1146
+ const ready = await post(safeDownload(job.id, format), {});
1147
+ // Only accept this job's same-origin URL; never buffer a dataset in
1148
+ // JavaScript. The browser streams the file and chooses where to save it.
1149
+ if (ready.url !== safeDownload(job.id, format))
1150
+ throw new Error(
1151
+ "The download could not be prepared. Please try again.",
1152
+ );
1153
+ const link = document.createElement("a");
1154
+ link.href = ready.url;
1155
+ link.download = ready.filename;
1156
+ document.body.append(link);
1157
+ link.click();
1158
+ link.remove();
1159
+ if (state.job?.id === job.id)
1160
+ $("download-status").textContent =
1161
+ `Download started: ${ready.filename}`;
1162
+ } catch (error) {
1163
+ if (state.job?.id === job.id) {
1164
+ $("download-status").textContent = "";
1165
+ $("download-error").textContent = error.message;
1166
+ $("download-error").hidden = false;
1167
+ }
1168
+ } finally {
1169
+ state.downloadPending = false;
1170
+ $("download-button").disabled = false;
1171
+ }
1172
+ }
1173
+
1174
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
1175
+ let c = n;
1176
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
1177
+ return c >>> 0;
1178
+ });
1179
+
1180
+ function crc32(bytes, crc = 0) {
1181
+ crc = ~crc >>> 0;
1182
+ for (let index = 0; index < bytes.length; index++)
1183
+ crc = CRC_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
1184
+ return ~crc >>> 0;
1185
+ }
1186
+
1187
+ // Reads a byte stream in exact-sized pieces without holding more than one
1188
+ // network chunk at a time.
1189
+ function byteReader(stream) {
1190
+ const reader = stream.getReader();
1191
+ const chunks = [];
1192
+ let length = 0;
1193
+ async function more() {
1194
+ const { done, value } = await reader.read();
1195
+ if (done) return false;
1196
+ if (value.length) {
1197
+ chunks.push(value);
1198
+ length += value.length;
1199
+ }
1200
+ return true;
1201
+ }
1202
+ function shift(limit) {
1203
+ const chunk = chunks[0];
1204
+ const size = Math.min(chunk.length, limit);
1205
+ const piece = chunk.subarray(0, size);
1206
+ if (size === chunk.length) chunks.shift();
1207
+ else chunks[0] = chunk.subarray(size);
1208
+ length -= size;
1209
+ return piece;
1210
+ }
1211
+ return {
1212
+ async take(count) {
1213
+ while (length < count)
1214
+ if (!(await more()))
1215
+ throw new Error("The dataset download ended early.");
1216
+ const result = new Uint8Array(count);
1217
+ for (let offset = 0; offset < count; ) {
1218
+ const piece = shift(count - offset);
1219
+ result.set(piece, offset);
1220
+ offset += piece.length;
1221
+ }
1222
+ return result;
1223
+ },
1224
+ async *pieces(count) {
1225
+ for (let left = count; left > 0; ) {
1226
+ if (!length && !(await more()))
1227
+ throw new Error("The dataset download ended early.");
1228
+ const piece = shift(left);
1229
+ left -= piece.length;
1230
+ yield piece;
1231
+ }
1232
+ },
1233
+ cancel: () => reader.cancel(),
1234
+ };
1235
+ }
1236
+
1237
+ // Only plain relative names are written; nothing can escape the chosen folder.
1238
+ function safeArchivePath(name) {
1239
+ const parts = name.split("/");
1240
+ if (
1241
+ !name ||
1242
+ name.length > 1024 ||
1243
+ /[\\\u0000-\u001f]/.test(name) ||
1244
+ parts.some(
1245
+ (part) =>
1246
+ !part || part === "." || part === ".." || part.length > 255,
1247
+ )
1248
+ )
1249
+ throw new Error(`The dataset contains an unsafe file name: ${name}`);
1250
+ return parts;
1251
+ }
1252
+
1253
+ // Write each entry of the server's uncompressed ZIP straight into a folder.
1254
+ async function extractStoredZip(stream, folder, onFile = () => {}) {
1255
+ const reader = byteReader(stream);
1256
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1257
+ const directories = new Map([["", folder]]);
1258
+ let files = 0;
1259
+ try {
1260
+ for (;;) {
1261
+ const signature = new DataView(
1262
+ (await reader.take(4)).buffer,
1263
+ ).getUint32(0, true);
1264
+ if (signature !== 0x04034b50) break; // The central directory follows the files.
1265
+ const header = new DataView((await reader.take(26)).buffer);
1266
+ const flags = header.getUint16(2, true);
1267
+ const method = header.getUint16(4, true);
1268
+ const expectedCrc = header.getUint32(10, true);
1269
+ let size = header.getUint32(18, true);
1270
+ const nameLength = header.getUint16(22, true);
1271
+ const extraLength = header.getUint16(24, true);
1272
+ const name = decoder.decode(await reader.take(nameLength));
1273
+ const extra = new DataView((await reader.take(extraLength)).buffer);
1274
+ if (method !== 0 || flags & 0x8)
1275
+ throw new Error(
1276
+ "The dataset archive uses an unsupported layout.",
1277
+ );
1278
+ if (size === 0xffffffff) {
1279
+ for (let offset = 0; offset + 4 <= extraLength; ) {
1280
+ const id = extra.getUint16(offset, true);
1281
+ const length = extra.getUint16(offset + 2, true);
1282
+ if (id === 1)
1283
+ size = Number(extra.getBigUint64(offset + 4, true));
1284
+ offset += 4 + length;
1285
+ }
1286
+ }
1287
+ const parts = safeArchivePath(name);
1288
+ let path = "";
1289
+ let directory = folder;
1290
+ for (const part of parts.slice(0, -1)) {
1291
+ path += `${part}/`;
1292
+ if (!directories.has(path))
1293
+ directories.set(
1294
+ path,
1295
+ await directory.getDirectoryHandle(part, {
1296
+ create: true,
1297
+ }),
1298
+ );
1299
+ directory = directories.get(path);
1300
+ }
1301
+ const handle = await directory.getFileHandle(parts.at(-1), {
1302
+ create: true,
1303
+ });
1304
+ const writable = await handle.createWritable();
1305
+ let crc = 0;
1306
+ try {
1307
+ for await (const piece of reader.pieces(size)) {
1308
+ crc = crc32(piece, crc);
1309
+ await writable.write(piece);
1310
+ }
1311
+ if (crc !== expectedCrc)
1312
+ throw new Error(`${name} was damaged during transfer.`);
1313
+ await writable.close();
1314
+ } catch (error) {
1315
+ await writable.abort?.();
1316
+ throw error;
1317
+ }
1318
+ onFile(++files, name);
1319
+ }
1320
+ } finally {
1321
+ reader.cancel().catch(() => {});
1322
+ }
1323
+ return files;
1324
+ }
1325
+
1326
+ // A dataset never overwrites existing files in the chosen folder.
1327
+ async function newDatasetFolder(parent, name) {
1328
+ let folder;
1329
+ try {
1330
+ folder = await parent.getDirectoryHandle(name);
1331
+ } catch (error) {
1332
+ if (error.name !== "NotFoundError") throw error;
1333
+ return parent.getDirectoryHandle(name, { create: true });
1334
+ }
1335
+ for await (const _ of folder.keys())
1336
+ throw new Error(
1337
+ `${parent.name}/${name} already exists and is not empty. Choose another folder.`,
1338
+ );
1339
+ return folder;
1340
+ }
1341
+
1342
+ async function saveToFolder(job, target) {
1343
+ if (state.saving) return;
1344
+ state.saving = true;
1345
+ $("save-button").disabled = true;
1346
+ $("save-error").hidden = true;
1347
+ const where = `${target.folder.name}/${job.name}`;
1348
+ $("save-status").textContent = `Preparing files for ${where}…`;
1349
+ let written = 0;
1350
+ try {
1351
+ const ready = await post(safeDownload(job.id, target.format), {});
1352
+ if (ready.url !== safeDownload(job.id, target.format))
1353
+ throw new Error(
1354
+ "The files could not be prepared. Please try again.",
1355
+ );
1356
+ const response = await fetch(ready.url);
1357
+ if (!response.ok) throw new Error(await errorMessage(response));
1358
+ const folder = await newDatasetFolder(target.folder, job.name);
1359
+ const files = await extractStoredZip(response.body, folder, (count) => {
1360
+ written = count;
1361
+ if (count % 25 === 0)
1362
+ $("save-status").textContent =
1363
+ `Saving to ${where}… ${count.toLocaleString()} files`;
1364
+ });
1365
+ target.saved = true;
1366
+ $("save-status").textContent =
1367
+ `Saved ${files.toLocaleString()} files to ${where}`;
1368
+ $("save-label").textContent = "Save to another folder…";
1369
+ toast(`Saved to ${where}`);
1370
+ } catch (error) {
1371
+ $("save-status").textContent = "";
1372
+ $("save-error").textContent = written
1373
+ ? `${error.message} The ${written.toLocaleString()} files already saved in ${where} were kept.`
1374
+ : error.message;
1375
+ $("save-error").hidden = false;
1376
+ } finally {
1377
+ state.saving = false;
1378
+ $("save-button").disabled = false;
1379
+ }
1380
+ }
1381
+
1382
+ async function saveCurrentJob() {
1383
+ if (state.job?.status !== "completed" || state.saving) return;
1384
+ if (!(await chooseFolder())) return;
1385
+ const target = {
1386
+ folder: state.folder,
1387
+ format: state.saveTargets.get(state.job.id)?.format || selectedFormat(),
1388
+ saved: false,
1389
+ };
1390
+ state.saveTargets.set(state.job.id, target);
1391
+ await saveToFolder(state.job, target);
1392
+ }
1393
+
1394
+ function showJob(job) {
1395
+ $("generation-results").hidden = false;
1396
+ const previous = state.job;
1397
+ state.job = job;
1398
+ $("job-name").textContent = job.name || "";
1399
+ $("job-status").textContent =
1400
+ {
1401
+ completed: "Ready",
1402
+ failed: "Failed",
1403
+ cancelled: "Cancelled",
1404
+ cancelling: "Stopping",
1405
+ }[job.status] || "Working";
1406
+ $("job-status").className = `status-badge ${job.status}`;
1407
+ const progress = Math.min(100, Math.max(0, Number(job.progress) || 0));
1408
+ $("job-progress").style.width = `${progress}%`;
1409
+ $("job-progress").classList.toggle(
1410
+ "indeterminate",
1411
+ !terminal.has(job.status) && progress === 0,
1412
+ );
1413
+ $("job-message").textContent = jobMessage(job);
1414
+ $("job-count").textContent =
1415
+ `${Number(job.generated || 0).toLocaleString()} / ${Number(job.requested || 0).toLocaleString()} images`;
1416
+ $("cancel-job").hidden = terminal.has(job.status);
1417
+ $("cancel-job").disabled = job.status === "cancelling";
1418
+ $("job-error").hidden = !job.error;
1419
+ $("job-error").textContent =
1420
+ typeof job.error === "string"
1421
+ ? job.error
1422
+ : job.error
1423
+ ? JSON.stringify(job.error)
1424
+ : "";
1425
+ const completed = job.status === "completed";
1426
+ const target = state.saveTargets.get(job.id);
1427
+ if (previous?.id !== job.id) {
1428
+ $("download-status").textContent = "";
1429
+ $("download-error").hidden = true;
1430
+ $("save-error").hidden = true;
1431
+ $("save-status").textContent = target?.saved
1432
+ ? `Saved to ${target.folder.name}/${job.name}`
1433
+ : canChooseFolder
1434
+ ? "Choose a folder to save this dataset."
1435
+ : "Download the dataset as a ZIP.";
1436
+ $("save-label").textContent = target?.saved
1437
+ ? "Save to another folder…"
1438
+ : "Save to a folder…";
1439
+ }
1440
+ $("save-panel").hidden = !completed;
1441
+ $("save-button").hidden = !canChooseFolder;
1442
+ $("gallery-section").hidden = !completed;
1443
+ if (completed && state.galleryJob !== job.id) loadGallery(job.id);
1444
+ if (!terminal.has(job.status)) state.runningId = job.id;
1445
+ else if (state.runningId === job.id) state.runningId = null;
1446
+ setBusy();
1447
+ if (completed && target && !target.saved && !state.saving)
1448
+ saveToFolder(job, target);
1449
+ else if (
1450
+ previous?.id === job.id &&
1451
+ !terminal.has(previous.status) &&
1452
+ completed
1453
+ )
1454
+ toast("Your dataset is ready.");
1455
+ }
1456
+
1457
+ async function loadGallery(id, offset = 0) {
1458
+ state.galleryController?.abort();
1459
+ const controller = new AbortController();
1460
+ state.galleryController = controller;
1461
+ const sequence = ++state.gallerySequence;
1462
+ $("gallery-count").textContent = "Loading…";
1463
+ $("gallery-previous").disabled = true;
1464
+ $("gallery-next").disabled = true;
1465
+ try {
1466
+ const result = await api(
1467
+ `/api/jobs/${encodeURIComponent(id)}/samples?offset=${offset}&limit=${GALLERY_PAGE}`,
1468
+ { signal: controller.signal },
1469
+ );
1470
+ if (sequence !== state.gallerySequence || state.job?.id !== id) return;
1471
+ state.galleryJob = id;
1472
+ state.galleryOffset = offset;
1473
+ const cards = result.samples.map((sample) => {
1474
+ const card = document.createElement("figure");
1475
+ card.className = "gallery-card";
1476
+ const link = document.createElement("a");
1477
+ link.className = "gallery-image";
1478
+ link.href = sample.image;
1479
+ link.target = "_blank";
1480
+ link.rel = "noopener";
1481
+ link.setAttribute(
1482
+ "aria-label",
1483
+ `Open image ${sample.id} full size`,
1484
+ );
1485
+ const img = document.createElement("img");
1486
+ img.src = sample.thumbnail || sample.image;
1487
+ img.alt = `Image ${sample.id}: ${sample.text}`;
1488
+ img.loading = "lazy";
1489
+ img.decoding = "async";
1490
+ link.append(img);
1491
+ const caption = document.createElement("figcaption");
1492
+ caption.textContent = sample.text;
1493
+ caption.title = sample.text;
1494
+ caption.dir = "auto";
1495
+ caption.lang = sample.language || "und";
1496
+ const meta = document.createElement("small");
1497
+ meta.textContent = `${sample.id} · ${fontLabel(sample.font)}`;
1498
+ card.append(link, caption, meta);
1499
+ return card;
1500
+ });
1501
+ $("sample-gallery").replaceChildren(...cards);
1502
+ const total = Number(result.total || 0);
1503
+ $("gallery-count").textContent = total
1504
+ ? `${offset + 1}–${offset + result.samples.length} of ${total.toLocaleString()}`
1505
+ : "No images";
1506
+ $("gallery-previous").disabled = offset === 0;
1507
+ $("gallery-next").disabled = offset + result.samples.length >= total;
1508
+ $("gallery-pagination").hidden = total <= GALLERY_PAGE;
1509
+ } catch (error) {
1510
+ if (error.name !== "AbortError" && sequence === state.gallerySequence)
1511
+ $("gallery-count").textContent = error.message;
1512
+ }
1513
+ }
1514
+
1515
+ function renderRecent() {
1516
+ const jobs = state.jobs.filter((job) => job.id !== state.job?.id);
1517
+ $("recent-section").hidden = jobs.length === 0;
1518
+ const list = $("recent-runs");
1519
+ list.replaceChildren();
1520
+ for (const job of jobs.slice(0, 5)) {
1521
+ const button = document.createElement("button");
1522
+ button.type = "button";
1523
+ button.className = "recent-run";
1524
+ const created = new Date(job.created_at);
1525
+ const title = document.createElement("strong");
1526
+ title.textContent = job.name;
1527
+ const description = document.createElement("small");
1528
+ description.textContent = `${Number(job.generated || 0).toLocaleString()} images · ${selectionName(job.languages, job.language)}${Number.isNaN(created.valueOf()) ? "" : ` · ${created.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`}`;
1529
+ const text = document.createElement("span");
1530
+ text.append(title, description);
1531
+ const status = document.createElement("span");
1532
+ status.className = `status-badge ${job.status}`;
1533
+ status.textContent = job.status === "completed" ? "Ready" : job.status;
1534
+ button.append(text, status);
1535
+ button.addEventListener("click", async () => {
1536
+ try {
1537
+ showJob(await api(`/api/jobs/${encodeURIComponent(job.id)}`));
1538
+ renderRecent();
1539
+ $("generation-results").scrollIntoView({
1540
+ behavior: "smooth",
1541
+ block: "start",
1542
+ });
1543
+ schedulePoll();
1544
+ } catch (error) {
1545
+ toast(error.message);
1546
+ }
1547
+ });
1548
+ list.append(button);
1549
+ }
1550
+ }
1551
+
1552
+ async function refreshJobs() {
1553
+ const response = await api("/api/jobs");
1554
+ state.jobs = response.jobs;
1555
+ const running = state.jobs.find((job) => !terminal.has(job.status));
1556
+ state.runningId = running?.id || null;
1557
+ setBusy();
1558
+ renderRecent();
1559
+ return running;
1560
+ }
1561
+
1562
+ function schedulePoll(delay = 1200) {
1563
+ clearTimeout(state.pollTimer);
1564
+ if (state.runningId || (state.job && !terminal.has(state.job.status)))
1565
+ state.pollTimer = setTimeout(pollJob, document.hidden ? 10000 : delay);
1566
+ }
1567
+
1568
+ async function pollJob() {
1569
+ try {
1570
+ const id = state.runningId || state.job?.id;
1571
+ if (!id) return;
1572
+ const job = await api(`/api/jobs/${encodeURIComponent(id)}`);
1573
+ if (state.job?.id === id) showJob(job);
1574
+ else if (terminal.has(job.status)) {
1575
+ state.runningId = null;
1576
+ setBusy();
1577
+ }
1578
+ state.jobs = state.jobs.map((entry) =>
1579
+ entry.id === job.id ? job : entry,
1580
+ );
1581
+ renderRecent();
1582
+ } catch (error) {
1583
+ $("job-message").textContent =
1584
+ `Connection lost. Retrying… ${error.message}`;
1585
+ } finally {
1586
+ schedulePoll();
1587
+ }
1588
+ }
1589
+
1590
+ async function cancelJob() {
1591
+ if (!state.job || terminal.has(state.job.status)) return;
1592
+ $("cancel-job").disabled = true;
1593
+ try {
1594
+ showJob(
1595
+ await post(
1596
+ `/api/jobs/${encodeURIComponent(state.job.id)}/cancel`,
1597
+ {},
1598
+ ),
1599
+ );
1600
+ schedulePoll(500);
1601
+ } catch (error) {
1602
+ toast(error.message);
1603
+ $("cancel-job").disabled = false;
1604
+ }
1605
+ }
1606
+
1607
+ async function initialize() {
1608
+ try {
1609
+ state.info = await api("/api/info");
1610
+ $("app-version").textContent = `v${state.info.version}`;
1611
+ for (const [name, preset] of Object.entries(
1612
+ state.info.image_presets || {},
1613
+ )) {
1614
+ const label = document.querySelector(
1615
+ `[data-style-label="${name}"]`,
1616
+ );
1617
+ const description = document.querySelector(
1618
+ `[data-style-description="${name}"]`,
1619
+ );
1620
+ if (label) label.textContent = preset.label;
1621
+ if (description) description.textContent = preset.description;
1622
+ }
1623
+ mountBackgroundOptions(state.info.document_presets || {});
1624
+ mountEffectOptions(state.info.document_layers || []);
1625
+ $("text-size").value = String(state.info.defaults?.font_size || 48);
1626
+ $("text-size-value").textContent = `${$("text-size").value} px`;
1627
+ await loadFonts();
1628
+ showStyleControls();
1629
+ updateSource();
1630
+ loadPreview();
1631
+ const running = await refreshJobs();
1632
+ suggestName();
1633
+ if (running) {
1634
+ showJob(running);
1635
+ schedulePoll();
1636
+ }
1637
+ } catch (error) {
1638
+ showPreviewError(error.message);
1639
+ setError(
1640
+ "SynthOCR could not be reached. Make sure the app is running, then refresh.",
1641
+ );
1642
+ }
1643
+ }
1644
+
1645
+ $("theme-toggle").addEventListener("click", () => {
1646
+ themePreference.set(
1647
+ document.documentElement.dataset.theme === "dark" ? "light" : "dark",
1648
+ );
1649
+ updateThemeButton();
1650
+ });
1651
+ $("generation-form").addEventListener("submit", submitJob);
1652
+ document
1653
+ .querySelectorAll('input[name="source"]')
1654
+ .forEach((input) => input.addEventListener("change", updateSource));
1655
+ document.querySelectorAll('input[name="language"]').forEach((input) =>
1656
+ input.addEventListener("change", () => {
1657
+ updateSource();
1658
+ if (selectedSource() === "upload" && $("corpus-file").files.length)
1659
+ uploadSelectedFile();
1660
+ }),
1661
+ );
1662
+ document.querySelectorAll('input[name="style"]').forEach((input) =>
1663
+ input.addEventListener("change", () => {
1664
+ state.style = input.value;
1665
+ state.custom = null;
1666
+ showStyleControls();
1667
+ schedulePreview();
1668
+ }),
1669
+ );
1670
+ $("effects-amount").addEventListener("input", () => {
1671
+ $("effects-amount-value").textContent = `${$("effects-amount").value}%`;
1672
+ readCustomControls();
1673
+ });
1674
+ $("background-options").addEventListener("change", readCustomControls);
1675
+ $("effect-options").addEventListener("change", readCustomControls);
1676
+ for (const name of Object.keys(GEOMETRY))
1677
+ $(GEOMETRY[name].id).addEventListener("input", readCustomControls);
1678
+ $("text-size").addEventListener("input", () => {
1679
+ $("text-size-value").textContent = `${$("text-size").value} px`;
1680
+ schedulePreview();
1681
+ });
1682
+ $("font-file").addEventListener("change", addFonts);
1683
+ $("font-list").addEventListener("change", (event) => {
1684
+ const choice = new Set(
1685
+ state.fontChoice || state.fontCatalog.map((font) => font.id),
1686
+ );
1687
+ if (event.target.checked) choice.add(event.target.value);
1688
+ else choice.delete(event.target.value);
1689
+ setFontChoice(choice);
1690
+ });
1691
+ $("fonts-all").addEventListener("click", () => setFontChoice(null));
1692
+ $("fonts-none").addEventListener("click", () => setFontChoice(new Set()));
1693
+ $("fonts-languages").addEventListener("click", () => {
1694
+ const tags = datasetLanguages();
1695
+ setFontChoice(
1696
+ new Set(
1697
+ state.fontCatalog
1698
+ .filter((font) =>
1699
+ tags.some((tag) => font.languages.includes(tag)),
1700
+ )
1701
+ .map((font) => font.id),
1702
+ ),
1703
+ );
1704
+ });
1705
+ $("corpus-file").addEventListener("change", () => {
1706
+ updateSource();
1707
+ uploadSelectedFile();
1708
+ });
1709
+ $("file-language-options").addEventListener("change", (event) => {
1710
+ state.fileLanguage = event.target.value;
1711
+ updateSource();
1712
+ uploadSelectedFile();
1713
+ });
1714
+ $("dataset-name").addEventListener("input", () => {
1715
+ state.nameEdited = true;
1716
+ updateFolder();
1717
+ });
1718
+ $("samples").addEventListener("input", () => {
1719
+ state.samplesEdited = true;
1720
+ updateShareCounts();
1721
+ });
1722
+ $("choose-folder").addEventListener("click", chooseFolder);
1723
+ $("refresh-preview").addEventListener("click", () => {
1724
+ state.previewIteration = (state.previewIteration + 1) % 32;
1725
+ loadPreview();
1726
+ });
1727
+ $("cancel-job").addEventListener("click", cancelJob);
1728
+ $("save-button").addEventListener("click", saveCurrentJob);
1729
+ // Downloads go straight through the browser; never buffer a dataset in JavaScript.
1730
+ $("download-button").addEventListener("click", () =>
1731
+ downloadDataset(
1732
+ state.saveTargets.get(state.job?.id)?.format || selectedFormat(),
1733
+ ),
1734
+ );
1735
+ $("gallery-previous").addEventListener("click", () =>
1736
+ loadGallery(state.job.id, Math.max(0, state.galleryOffset - GALLERY_PAGE)),
1737
+ );
1738
+ $("gallery-next").addEventListener("click", () =>
1739
+ loadGallery(state.job.id, state.galleryOffset + GALLERY_PAGE),
1740
+ );
1741
+ $("generate-button").disabled = true;
1742
+ document.addEventListener("visibilitychange", () => {
1743
+ if (document.hidden) {
1744
+ clearTimeout(state.previewTimer);
1745
+ state.previewController?.abort();
1746
+ state.galleryController?.abort();
1747
+ } else {
1748
+ schedulePreview();
1749
+ if (
1750
+ state.job?.status === "completed" &&
1751
+ state.galleryJob !== state.job.id
1752
+ )
1753
+ loadGallery(state.job.id, state.galleryOffset);
1754
+ }
1755
+ schedulePoll(document.hidden ? 10000 : 100);
1756
+ });
1757
+ updateThemeButton();
1758
+ updateFolder();
1759
+ initialize();