-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmacronizer.html
More file actions
2233 lines (2051 loc) · 118 KB
/
Copy pathmacronizer.html
File metadata and controls
2233 lines (2051 loc) · 118 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link as="style" href="css/tacit-css-1.7.1.min.css"
onload="this.onload=null;this.rel='stylesheet'"
rel="preload">
<noscript>
<link href="css/tacit-css-1.7.1.min.css" rel="stylesheet">
</noscript>
<link href="icons/macronizer/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180">
<link href="icons/macronizer/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png">
<link href="icons/macronizer/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png">
<link href="icons/macronizer/favicon.ico" rel="icon">
<link href="https://fonts.googleapis.com" rel="preconnect"/>
<link crossorigin href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="css/style.css" rel="stylesheet" type="text/css">
<!-- Warm the wordlist download: the engine fetches macrons.txt.gz only AFTER the
WASM assets are fetched+gunzipped (a serialized second round-trip). Preloading
it lets the browser fetch it in parallel, cutting first-visit latency. -->
<link as="fetch" crossorigin href="macronizer/macrons.txt.gz" rel="preload">
<style>
/* =====================================================================
ONE COLUMN. Every block — input, options, buttons, result, meta —
starts at the same left edge and ends at the same right edge. The old
layout mixed a centred chrome with left-aligned content and gave the
textarea its own inset, so no two consecutive blocks lined up.
===================================================================== */
#content { max-width: 780px; margin: 0 auto; }
/* Mobile: the column ran edge-to-edge with no breathing room — the transcriber
insets body 18px under 767px, the macronizer never did. Match that idiom.
Deliberately NOT env(safe-area-inset-*): the column is centered and narrow, so
it never reaches a notch — and on landscape the right inset can be ~44px, which
shows up as a large empty gap on the right. A fixed 18px keeps it symmetric.
The sheet/scrim are position:fixed, so body padding doesn't move them. */
@media (max-width: 767px) {
body { padding-left: 18px; padding-right: 18px; }
}
/* tacit's base body padding is 36px all around, but its own ≤767 rule drops to
"18px 0". Without this, body padding jumps 18px→36px at exactly 768px and the
content column SHRINKS (731→696px) as the viewport widens. Smooth it through
the range where the 780px cap still leaves room; ≥852 the centred column's own
gutters exceed 36px anyway, so tacit's value is invisible there. */
@media (min-width: 768px) and (max-width: 851px) {
body { padding-left: 18px; padding-right: 18px; }
}
#header { align-items: center; }
#header > h1 { margin-left: 14px; padding-left: 0; font-size: 28px; }
#header #home { display: inline-flex; align-items: center; }
#content > *, #result, #export_buttons, .wordlist-section { text-align: left; }
/* One type system: Garamond for the Latin, Noto Sans for everything else
(buttons and selects were falling back to system-ui). */
button, select, option, input, textarea, .btn { font-family: 'Noto Sans', sans-serif; }
#text_to_macronize, .macron-line { font-family: 'EB Garamond', serif; }
/* ----- Input ----- */
/* style.css uses `div#form_top` / `div#form_bottom` (specificity 0,1,1), so a plain
#id selector here loses and the old flex-centred layout survives. Match it. */
#frm1 { margin-bottom: 0; }
div#form_top { display: block; max-width: none; margin-bottom: 10px; }
#text_to_macronize { width: 100%; box-sizing: border-box; font-size: 18px; margin: 0; }
/* style.css: `div#clear_button_group { flex-direction: column }` — again 0,1,1 */
div#clear_button_group { display: flex; flex-direction: row; justify-content: flex-end; margin: 2px 0 0; }
#clear_button { padding: 2px 0; }
/* ----- Options: every control the same height, size and rhythm -----
They were a mix of oversized checkbox labels on one row and a lone select on
another. Now: one wrapping row, one 15px label style, one 32px control height,
each option a <label> so the whole chip is a click target. */
.options { margin: 0 0 14px; text-align: left; }
.options-row { display: flex; flex-wrap: wrap; justify-content: flex-start; gap: 8px 10px;
align-items: stretch; margin: 0; }
.option-group { display: inline-flex; align-items: center; gap: 8px; height: 32px;
padding: 0 12px; margin: 0; white-space: nowrap; cursor: pointer;
border: 1px solid #e0e0e0; border-radius: 16px; background: #fafafa; }
.option-group:hover { border-color: #9C27B0; }
.option-group span, .option-group label { font-size: 15px; margin: 0; cursor: pointer; }
.options input[type="checkbox"] { width: 16px; height: 16px; margin: 0; flex: 0 0 auto;
accent-color: #6a1b9a; }
body.dark_mode .options input[type="checkbox"] { accent-color: #CE93D8; }
/* the checked options should read as "on" at a glance */
.option-group:has(input:checked) { background: #f3e5f5; border-color: #9C27B0; color: #6a1b9a; }
.option-select { padding-right: 6px; }
.option-select label { color: #888; }
.options select { margin: 0; height: 24px; padding: 0 4px; font-size: 15px;
border: none; background: transparent; }
/* The "Scan as" chip holds a <select> and is the widest chip. On a narrow phone
its intrinsic (min-content) width exceeds the content column, so the flex row
can't wrap it and pushes #content's scrollWidth past the viewport. Let the chip
shrink: the select truncates, and the chip can then wrap below its peers. */
.option-select { min-width: 0; max-width: 100%; }
.option-select select { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
body.dark_mode .option-group { background: #1a1620; border-color: #332c3a; }
body.dark_mode .option-group:hover { border-color: #CE93D8; }
body.dark_mode .option-group:has(input:checked) { background: #2e2436; border-color: #CE93D8; color: #CE93D8; }
body.dark_mode .option-select label { color: #999; }
body.dark_mode .options select { color: #e0e0e0; }
body.dark_mode .options select option { background: #1F1B24; }
/* ----- Results: word-cell system (app idiom) ----- */
/* resize:vertical is inherited from tacit's table rules and draws a pointless
drag-grip in the corner of the results — kill it. */
#result_table { width: 100%; border-collapse: collapse; margin-top: 8px; resize: none; }
/* tacit gives <tr> an 11px left margin — it pushed the result text off the column edge */
tr.line { display: block; text-align: left; padding: 2px 0; margin: 0; background: transparent; border: none; }
tr.line td { display: block; border: none; padding: 2px 0; overflow: visible; }
.macron-line { font-size: 20px; line-height: 1.9; }
.macron-line .ipa { cursor: default; margin-right: 0; }
.macron-line .ipa.multiple-values { cursor: pointer; font-family: inherit; font-weight: bold; }
/* The ambig/unknown flags live ON the span now (text is real textContent),
not on the ::before pseudo-element — that painter is gone. */
.macron-line .ambig { background: #fff3cd; padding: 0 3px; border-radius: 3px; cursor: help; }
.macron-line .unknown { background: #f8d7da; padding: 0 3px; border-radius: 3px; cursor: help; }
/* Words are real textContent now; the shared .ipa::before painter (style.css)
would paint them a second time via attr(content). Kill it here, scoped —
the transcriber still needs that painter on its own page. */
#resultText .ipa::before { content: none; }
.verse-foot { font-family: 'Courier New', Courier, monospace; font-weight: bold; font-size: 13px;
color: #6a1b9a; background: #f3e5f5; padding: 1px 8px; border-radius: 4px;
margin-left: 12px; white-space: nowrap; vertical-align: middle; }
.verse-foot.no-scan { color: #999; background: #f0f0f0; font-style: italic; }
/* ----- Word detail popup (hover) ----- */
/* fixed, not absolute: an absolutely-positioned popup adds to the document height,
which makes the page grow/shrink (and the scrollbar appear/disappear) as it opens
and closes — the content jumps under the cursor. Fixed takes it out of that flow. */
.word-popup { position: fixed; background: white; border: 1px solid #ccc; border-radius: 5px;
padding: 10px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 10000;
max-width: 380px; font-family: 'Noto Sans', sans-serif; font-size: 13px;
line-height: 1.45; text-align: left; }
/* tacit sets h1–h6 color:#000, so the heading must restate the color here —
otherwise "divisa → dīvīsa" renders black on the dark popup. */
.word-popup h4 { margin: 0 0 8px 0; font-size: 15px; border-bottom: 1px solid #eee; padding-bottom: 5px; color: #333; }
.word-popup table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 0; }
.word-popup td { padding: 2px 6px; border: none; overflow: visible; display: table-cell; }
.word-popup tr { display: table-row; border: none; margin: 0; }
.word-popup td:first-child { color: #666; font-weight: 500; white-space: nowrap; vertical-align: top; }
.popup-section { margin-bottom: 9px; }
.popup-section:last-child { margin-bottom: 0; }
.popup-section-title { font-weight: 600; font-size: 11px; color: #888; text-transform: uppercase;
letter-spacing: 0.5px; border-bottom: 1px solid #e0e0e0; padding-bottom: 3px; margin-bottom: 5px; }
.word-popup .candidate { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 8px;
background: #f3e5f5; color: #6a1b9a; border-radius: 3px; font-size: 12px; }
.word-popup .tag-desc { color: #888; font-style: italic; font-size: 11px; }
.word-popup .popup-note { margin-top: 6px; font-size: 11px; line-height: 1.4; color: #888; }
body.dark_mode .word-popup .popup-note { color: #9d9d9d; }
/* Readings table: spelling | lemma | grammar — each row is one wordlist entry,
so the lemma can differ from the token's single best-frequency lemma. */
.word-popup table.readings { width: 100%; margin: 2px 0 0; }
.word-popup table.readings td { padding: 3px 6px; vertical-align: top; font-size: 12px; }
.word-popup table.readings .r-form { white-space: nowrap; font-weight: 600; color: #6a1b9a; }
.word-popup table.readings .r-lemma { white-space: nowrap; font-style: italic; color: #555; }
.word-popup table.readings .r-def { color: #666; font-size: 11px; line-height: 1.35; }
.word-popup table.readings .r-def .dash { color: #bbb; }
.word-popup table.readings .r-gram { color: #888; font-size: 11px; }
.word-popup table.readings tr.active { background: #f3e5f5; }
.word-popup table.readings tr.active .r-form { color: #4a1069; }
body.dark_mode .word-popup table.readings .r-form { color: #CE93D8; }
body.dark_mode .word-popup table.readings .r-lemma { color: #bbb; }
body.dark_mode .word-popup table.readings .r-def { color: #aaa; }
body.dark_mode .word-popup table.readings .r-def .dash { color: #777; }
body.dark_mode .word-popup table.readings .r-gram { color: #999; }
body.dark_mode .word-popup table.readings tr.active { background: #3a2f42; }
.word-popup .popup-cycle { display: block; margin: 8px 0 0; padding: 5px 10px; width: 100%;
background: transparent; border: 1px solid #9C27B0; border-radius: 4px;
color: #6a1b9a; font-size: 12px; cursor: pointer; }
.word-popup .popup-cycle:hover { background: #f3e5f5; }
body.dark_mode .word-popup .popup-cycle { border-color: #CE93D8; color: #CE93D8; }
body.dark_mode .word-popup .popup-cycle:hover { background: #3a2f42; }
/* The readings come first; the analysis (RFTagger / Details / Morpheus) is
debug-oriented and tucked behind a collapsible <details>. */
.word-popup details.popup-analysis { margin-top: 8px; border-top: 1px solid #eee; padding-top: 6px; }
.word-popup details.popup-analysis summary { cursor: pointer; font-size: 12px; color: #888; font-weight: 600; }
.word-popup details.popup-analysis summary:hover { color: #6a1b9a; }
body.dark_mode .word-popup details.popup-analysis { border-top-color: #333; }
body.dark_mode .word-popup details.popup-analysis summary:hover { color: #CE93D8; }
/* ----- Legend: the highlight colours are the whole point — name them ----- */
/* The result follows the button directly — no rule, no gulf of whitespace. */
#result { margin-top: 18px; padding-top: 0; border-top: none; }
.result-heading { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
color: #6f6f6f; margin: 0 0 8px; }
body.dark_mode #result { border-color: #2a2430; }
body.dark_mode .result-heading { color: #aaa; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.legend { display: flex; flex-wrap: wrap; justify-content: flex-start; gap: 6px 20px;
font-size: 13px; color: #666; margin-bottom: 4px; }
/* Each legend chip ("ā more than one reading…") must be allowed to wrap on a narrow
phone: white-space:nowrap made a chip unbreakable, so when it was wider than the
column flex-wrap couldn't wrap it and it overflowed #content (empty space on the
right below ~300px). The i-swatch and its text stay together; only the full chip
wraps to the next line. */
.legend span { white-space: normal; }
.legend i { white-space: nowrap; }
.legend i { font-style: normal; padding: 0 6px; border-radius: 3px; margin-right: 5px; }
.legend .sw-ambig { background: #fff3cd; }
.legend .sw-unknown { background: #f8d7da; }
body.dark_mode .legend { color: #aaa; }
body.dark_mode .legend .sw-ambig { background: #5c4a10; }
body.dark_mode .legend .sw-unknown { background: #5c2020; }
.stats { margin-top: 12px; font-size: 13px; color: #6f6f6f; text-align: left; }
.loading { display: none; text-align: left; padding: 18px 0; font-size: 14px; color: #666; }
.loading.show { display: block; }
body.dark_mode .loading { color: #aaa; }
#error { color: red; padding: 10px; margin-top: 10px; }
/* ----- Wordlist: ONE quiet status line, not five stacked centred rows ----- */
.wordlist-section { margin-top: 32px; padding-top: 10px; border-top: 1px solid #eee;
font-size: 12px; color: #999; display: flex; flex-wrap: wrap;
align-items: center; gap: 8px 14px; }
.wordlist-section .btn { font-size: 12px; padding: 4px 10px; width: auto; margin: 0; }
#wlPrompt { font-size: 13px; color: #6a1b9a; }
#wlButtons { display: flex; gap: 8px; align-items: center; }
#wlProgress { flex: 1 1 220px; min-width: 180px; }
#wlProgressBar { background: #9C27B0; width: 0%; height: 8px; border-radius: 4px; transition: width 0.3s; }
#wlInfo { font-size: 12px; color: #6f6f6f; margin: 0; margin-left: auto; }
body.dark_mode .wordlist-section { border-color: #2a2430; }
/* Clear cache is rare and destructive: a quiet red link, not a button competing
with the exports for attention. */
.wordlist-section #clearCache { background: none; border: none; padding: 0; color: #c62828;
text-decoration: underline; font-size: 12px; box-shadow: none; }
.wordlist-section #clearCache:hover:not(:disabled) { background: none; color: #8e1f1f; }
.wordlist-section #clearCache:disabled { color: #ccc; text-decoration: none; }
body.dark_mode .wordlist-section #clearCache { background: none; color: #ef9a9a; }
body.dark_mode .wordlist-section #clearCache:disabled { color: #5a4a4a; }
/* ----- First-visit notice: the assets are big, say so before the wait ----- */
#firstRun { margin: 0 0 22px; padding: 14px 18px; border: 1px solid #9C27B0;
border-radius: 6px; background: #f8f0fb; font-size: 14px; text-align: left; }
#firstRun p { margin: 6px 0 10px; color: #444; }
#firstRunTitle { font-weight: bold; color: #6a1b9a; }
#firstRunStatus { font-size: 13px; color: #6a1b9a; }
#firstRun.done { border-color: #4CAF50; background: #f1f8f2; }
#firstRun.done #firstRunTitle, #firstRun.done #firstRunStatus { color: #2e7d32; }
#procProgress { width: 100%; margin: 10px 0 0; background: #eee; border-radius: 4px; overflow: hidden; }
#procProgressBar { background: #9C27B0; width: 0%; height: 8px; border-radius: 4px; transition: width 0.2s; }
.credits { font-size: 12px; color: #6f6f6f; margin: 6px 0 0; }
.credits a { color: #6a1b9a; }
/* ----- Button hierarchy: exactly one primary action (Macronize) ----- */
/* The shared .btn-primary is grey in light mode (it only turns purple in dark), so the
main action read as *disabled* next to any outlined button. Give it a real accent fill
here — local to this page, so index.html's buttons are untouched. */
.btn.btn-primary { background: #6a1b9a; border: 1px solid #6a1b9a; color: #fff; }
.btn.btn-primary:hover:not(:disabled) { background: #58157f; border-color: #58157f; }
.btn.btn-primary:disabled { background: #d9c7e2; border-color: #d9c7e2; color: #fff; }
body.dark_mode .btn.btn-primary { background: #CE93D8; border-color: #CE93D8; color: #1F1B24; }
body.dark_mode .btn.btn-primary:hover:not(:disabled) { background: #BA68C8; border-color: #BA68C8; }
body.dark_mode .btn.btn-primary:disabled { background: #4a3a52; border-color: #4a3a52; color: #8d8195; }
.btn.btn-secondary { background: transparent; border: 1px solid #9C27B0; color: #6a1b9a; }
.btn.btn-secondary:hover:not(:disabled) { background: #f3e5f5; }
body.dark_mode .btn.btn-secondary { background: transparent; border-color: #CE93D8; color: #CE93D8; }
body.dark_mode .btn.btn-secondary:hover:not(:disabled) { background: #3a2f42; }
.btn.btn-danger { background: transparent; border: 1px solid #c62828; color: #c62828; }
.btn.btn-danger:hover:not(:disabled) { background: #fdecec; }
body.dark_mode .btn.btn-danger { background: transparent; border-color: #ef9a9a; color: #ef9a9a; }
body.dark_mode .btn.btn-danger:hover:not(:disabled) { background: #3a2222; }
/* "Clear form" is not an action worth a filled button — make it a quiet link */
#clear_button_group { display: flex; align-items: flex-start; }
#clear_button { background: none; border: none; color: #888; font-size: 13px; white-space: nowrap;
padding: 4px 8px; cursor: pointer; text-decoration: underline; box-shadow: none; }
#clear_button:hover { color: #6a1b9a; }
body.dark_mode #clear_button { color: #999; }
body.dark_mode #clear_button:hover { color: #CE93D8; }
/* Button rows sit on the column's left edge and size to their content —
btn-block was stretching them into full-width slabs. */
div#form_bottom, #export_buttons { display: flex; flex-wrap: wrap; justify-content: flex-start;
gap: 10px; width: auto; }
div#form_bottom .btn, #export_buttons .btn { width: auto; flex: 0 0 auto; margin: 0;
padding: 8px 18px; font-size: 15px; }
/* Embedded split button (CSV): one control — body = last-used mode, caret
inside opens the mode menu. The divider uses the button's own stroke so it
reads as one unit, not two glued buttons. */
#export_buttons .btn-split { position: relative; display: inline-flex; flex: 0 0 auto; }
#export_buttons .btn-split .btn { flex: 0 0 auto; }
#export_buttons .btn-split .btn-split-main { border-radius: 4px 0 0 4px; border-right: none; }
#export_buttons .btn-split .btn-split-caret { border-radius: 0 4px 4px 0; border-left: 1px solid currentColor;
padding: 8px 6px; min-width: 30px; }
/* highlight as one unit, not per half */
#export_buttons .btn-split:hover .btn-split-main,
#export_buttons .btn-split:hover .btn-split-caret { background: #f3e5f5; color: #6a1b9a; }
.btn-split-menu { position: absolute; top: 100%; right: 0; z-index: 50; min-width: 170px;
margin: 4px 0 0; background: #fff; border: 1px solid #ddd; border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); padding: 4px; list-style: none; }
.btn-split-menu li { display: flex; justify-content: space-between; align-items: center;
padding: 7px 10px; font-size: 13px; cursor: pointer; color: #333; border-radius: 3px; }
.btn-split-menu li:hover, .btn-split-menu li:focus-visible { background: #f3e5f5; color: #6a1b9a; }
.btn-split-menu li:focus-visible { outline: 2px solid #9C27B0; outline-offset: -2px; }
.btn-split-menu li .check { color: #6a1b9a; margin-left: 12px; }
body.dark_mode .btn-split-menu { background: #1F1B24; border-color: #333; }
body.dark_mode .btn-split-menu li { color: #eee; }
body.dark_mode .btn-split-menu li:hover, body.dark_mode .btn-split-menu li:focus-visible { background: #3a2f42; color: #CE93D8; }
body.dark_mode .btn-split-menu li:focus-visible { outline-color: #CE93D8; }
body.dark_mode .btn-split-menu li .check { color: #CE93D8; }
/* ----- Hints: name the jargon in place ----- */
.hint { font-size: 12px; color: #6f6f6f; text-align: left; margin: 8px 0 0; }
.hint a, .help-link { color: #6a1b9a; }
body.dark_mode .hint { color: #999; }
body.dark_mode .hint a, body.dark_mode .help-link { color: #CE93D8; }
#header .help-link { font-size: 14px; text-decoration: none; margin-right: 14px; }
/* ----- Word cells are real controls: focusable, and they say so ----- */
.macron-line .ipa:focus-visible { outline: 2px solid #9C27B0; outline-offset: 2px; border-radius: 3px; }
body.dark_mode .macron-line .ipa:focus-visible { outline-color: #CE93D8; }
/* Flagged words are the ones you inspect — give the focus ring a solid target. */
.macron-line .ipa.ambig:focus-visible, .macron-line .ipa.unknown:focus-visible { outline: 2px solid #9C27B0; outline-offset: 2px; }
body.dark_mode .macron-line .ipa.ambig:focus-visible, body.dark_mode .macron-line .ipa.unknown:focus-visible { outline-color: #CE93D8; }
/* Currently-shown candidate, so cycling has a visible position */
.word-popup .candidate.active { background: #6a1b9a; color: #fff; font-weight: bold; }
body.dark_mode .word-popup .candidate.active { background: #CE93D8; color: #1F1B24; }
/* Readings rows are tappable — picking a reading chooses it. */
.word-popup table.readings tr { cursor: pointer; }
.word-popup table.readings tr:active { background: #e1bee7; }
body.dark_mode .word-popup table.readings tr:active { background: #4a3a52; }
/* On phones the floating popup covers the text it describes — dock it instead */
.word-popup.sheet { position: fixed; left: 0 !important; right: 0; bottom: 0; top: auto !important;
max-width: none; max-height: 60vh; overflow-y: auto;
border-radius: 10px 10px 0 0; border-bottom: none;
box-shadow: 0 -3px 14px rgba(0,0,0,0.25); padding-bottom: 16px; }
.word-popup .popup-close { display: none; }
.word-popup.sheet .popup-close { display: block; position: absolute; top: 6px; right: 10px;
background: none; border: none; font-size: 22px; line-height: 1;
color: #888; cursor: pointer; padding: 2px 6px; }
/* A grab handle at the sheet's top edge signals it can be pulled down. */
.word-popup.sheet::before { content: ''; display: block; width: 36px; height: 4px;
border-radius: 2px; background: #ccc; margin: 8px auto 6px; }
body.dark_mode .word-popup.sheet::before { background: #5a5a5a; }
/* Scrim behind the docked sheet: dims the page and gives a big tap-to-dismiss. */
#sheet-scrim { display: none; position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.4); }
body.dark_mode #sheet-scrim { background: rgba(0,0,0,0.6); }
/* Bigger touch targets on coarse pointers: the sheet's rows and buttons. */
@media (pointer: coarse) {
.word-popup.sheet .popup-close { padding: 8px 12px; font-size: 24px; }
.word-popup .popup-cycle { min-height: 44px; }
.word-popup table.readings td { padding: 9px 6px; }
}
/* ----- Dark mode (app palette: #121212 page / #1F1B24 surface / #CE93D8 accent) ----- */
/* Lets the browser paint native controls (checkboxes, select) dark instead of
stark white — no custom checkbox markup needed. */
body.dark_mode { color-scheme: dark; }
body.dark_mode .stats { color: #999; }
body.dark_mode #wlPrompt { color: #CE93D8; }
body.dark_mode .verse-foot { background: #1F1B24; color: #CE93D8; }
/* specificity (0,2,1) beats the plain dark .verse-foot — keep the muted
"no scansion" placeholder distinct from a real foot in dark mode too */
body.dark_mode .verse-foot.no-scan { color: #777; background: #2a2a2a; }
body.dark_mode .macron-line .ambig { background: #5c4a10; }
body.dark_mode .macron-line .unknown { background: #5c2020; }
body.dark_mode #wlProgressBar { background: #CE93D8; }
body.dark_mode #wlInfo { color: #aaa; }
body.dark_mode #firstRun { background: #1F1B24; border-color: #9C27B0; }
body.dark_mode #firstRun p { color: #ccc; }
body.dark_mode #firstRunTitle, body.dark_mode #firstRunStatus { color: #CE93D8; }
body.dark_mode #firstRun.done { background: #1b241d; border-color: #4CAF50; }
body.dark_mode #firstRun.done #firstRunTitle, body.dark_mode #firstRun.done #firstRunStatus { color: #81C784; }
body.dark_mode #procProgress { background: #2a2430; }
body.dark_mode #procProgressBar { background: #CE93D8; }
body.dark_mode .credits { color: #999; }
body.dark_mode .credits a { color: #CE93D8; }
body.dark_mode .word-popup { background: #1F1B24; color: #e0e0e0; border-color: #9C27B0; box-shadow: none; }
body.dark_mode .word-popup h4 { color: #e0e0e0; border-color: #3a2f42; }
body.dark_mode .word-popup td:first-child { color: #aaa; }
body.dark_mode .popup-section-title { color: #aaa; border-color: #3a2f42; }
body.dark_mode .word-popup .candidate { background: #3a2f42; color: #CE93D8; }
</style>
<title>Latin Macronizer</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
<meta content="" name="Keywords"/>
<meta content="Latin macronizer — full pipeline with RFTagger POS tagging, Morpheus morphological analysis, and verse scansion."
name="description">
</head>
<body>
<div id="content">
<br/>
<div id="header">
<a href="../" id="home" title="Home">
<i class="icon icon-home"></i>
</a>
<h1 style="flex: 1; text-align: left;">Latin Macronizer</h1>
<a href="help/macronizer.html" class="help-link" title="What is this? How do I read the output?">Help</a>
<a href="#" id="dark_mode">
<i class="icon icon-moon"></i>
</a>
</div>
<hr>
<div id="firstRun" style="display:none;">
<div id="firstRunTitle">Setting up — one-time download</div>
<p>
The macronizer runs entirely in your browser: it needs the POS-tagger model, the Morpheus
morphology engine and an 812,000-form wordlist — about <strong>10 MB</strong> to
download (65 MB once unpacked). This happens <strong>once</strong>; afterwards it is
stored in your browser, so every later visit starts in a few seconds and works offline.
<a href="help/macronizer.html">What is this tool?</a>
</p>
<div id="firstRunStatus">Starting…</div>
</div>
<form id="frm1" onsubmit="return false">
<div class="form-group">
<!-- Options first: they decide what Macronize will do, so they belong before the text. -->
<div class="options">
<div class="options-row">
<label class="option-group" for="macronize">
<input type="checkbox" id="macronize" checked>
<span>Mark long vowels</span>
</label>
<label class="option-group" for="alsomaius">
<input type="checkbox" id="alsomaius">
<span>Also mark <i>māius</i> etc.</span>
</label>
<label class="option-group" for="utov">
<input type="checkbox" id="utov">
<span><i>u</i> → <i>v</i></span>
</label>
<label class="option-group" for="itoj">
<input type="checkbox" id="itoj">
<span><i>i</i> → <i>j</i></span>
</label>
<span class="option-group option-select">
<label for="scan">Scan as</label>
<select id="scan" class="dropdown">
<option value="prose">Prose (no scansion)</option>
<option value="dactylichexameter">Dactylic hexameter</option>
<option value="elegiacdistichs">Elegiac distichs</option>
<option value="hendecasyllable">Hendecasyllable</option>
<option value="iambic">Iambic trimeter + dimeter</option>
</select>
</span>
</div>
<p class="hint">
<strong>Scan as</strong> fits the vowel lengths to a verse metre — leave it on Prose
for ordinary text. <a href="help/macronizer.html">More about the options</a>.
</p>
</div>
<div id="form_top">
<textarea
autofocus=""
id="text_to_macronize"
name="text_to_macronize"
rows="6"
required=""
>Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur.</textarea>
<div id="clear_button_group">
<button id="clear_button" type="button">Clear form</button>
</div>
</div>
<div class="btn-group btn-block" id="form_bottom" role="group">
<button autocomplete="off" class="btn btn-primary btn-block"
id="macronize_btn" name="macronize" type="button" disabled>Macronize</button>
<button autocomplete="off" class="btn btn-secondary btn-block"
id="demacronize" name="demacronize" type="button"
title="Show the text below with all macrons stripped out">De-macronize</button>
</div>
</div>
</form>
<div id="loading" class="loading" style="display:none">
<div id="loadingText">Processing…</div>
<div id="procProgress"><div id="procProgressBar"></div></div>
</div>
<div id="result" style="display: none;">
<h2 class="result-heading">Macronized</h2>
<div class="legend" id="legend"></div>
<!-- sr-only caption as a table SIBLING, not a <caption>: a table <caption> does
not clip with overflow:hidden — its full unclipped text forces the table's
intrinsic width, so at narrow viewports the document gained horizontal
scrollWidth (the "empty space on the right"). A sibling keeps SR semantics
without participating in table layout. -->
<div class="sr-only" id="result_table_desc">Macronized text. Highlighted words have more than
one possible reading; tap or press Enter on a highlighted word to see its analysis and
choose a reading. On a computer, click a vowel to toggle its macron. The output is
editable — click and type to correct it.</div>
<table id="result_table" aria-describedby="result_table_desc">
<tbody id="resultText"></tbody>
</table>
<div class="stats" id="stats" aria-live="polite"></div>
</div>
<div id="error" style="display: none;"></div>
<div class="btn-group btn-block" id="export_buttons" role="group" style="margin-top:15px; display:none;">
<button autocomplete="off" class="btn btn-primary btn-block" id="copy_btn" type="button" disabled>
Copy text
</button>
<button autocomplete="off" class="btn btn-secondary btn-block" id="export_pdf" type="button" disabled>
Export as PDF <i style="color:#e90101; font-size:30px;" class="icon icon-pdf"></i>
</button>
<div class="btn-split">
<button autocomplete="off" class="btn btn-secondary btn-block btn-split-main" id="export_csv" type="button" title="Export as CSV — per word" disabled>
Export CSV <i style="color:#03731e; font-size:30px;" class="icon icon-csv"></i>
</button>
<button autocomplete="off" class="btn btn-secondary btn-block btn-split-caret" id="export_csv_caret" type="button" title="CSV layout: per word or per line" aria-haspopup="menu" aria-expanded="false" aria-controls="csv_menu" aria-label="CSV export options" disabled>▾</button>
<ul class="btn-split-menu" id="csv_menu" role="menu" hidden>
<li role="menuitem" tabindex="0" data-csv-mode="word" title="One row per word: text, macronized, tag, lemma">Per word <span class="check" hidden>✓</span></li>
<li role="menuitem" tabindex="0" data-csv-mode="line" title="One row per line of text">Per line <span class="check" hidden>✓</span></li>
</ul>
</div>
</div>
<div class="wordlist-section">
<div id="wlPrompt" style="display: none;">
Load the wordlist to start (3.8 MB download, ~812k entries)
</div>
<div id="wlButtons">
<button id="loadIndexedDB" class="btn" disabled>Load into IndexedDB</button>
<button id="loadMemory" class="btn" disabled>Load into memory</button>
<button id="clearCache" class="btn btn-danger" disabled
title="Deletes the downloaded language data from your browser">Clear cache</button>
</div>
<div id="wlProgress" style="display:none;">
<div style="display:flex; justify-content:space-between; margin-bottom:3px;">
<span id="wlStatus">Downloading...</span>
<span id="wlPercent">0%</span>
</div>
<div style="background:#ddd; border-radius:4px; height:8px; overflow:hidden;">
<div id="wlProgressBar" style="width:0%; height:100%; transition:width 0.3s;"></div>
</div>
</div>
<p id="wlInfo">
<span id="wlModeDisplay">Wordlist: <strong>IndexedDB</strong></span>
<span id="wlEntryCount"> | <em>not loaded</em></span>
</p>
</div>
</div>
<hr>
<div id="footer">
<a href="https://github.com/hellpanderrr/hellpanderrr.github.io/tree/main/wiktionary_pron" style="color: black;">
<i class="icon icon-git" style="font-size:36px"></i>
</a>
<p class="credits">
Macronizer engine:
<a href="https://github.com/hellpanderrr/latin-macronizer-wasm" target="_blank" rel="noopener noreferrer">latin-macronizer-wasm</a>
— a WebAssembly port of the
<a href="https://github.com/Alatius/latin-macronizer" target="_blank" rel="noopener noreferrer">Latin macronizer</a>
by Johan Winge.
</p>
</div>
<img style="display: none;"
src="https://hitscounter.dev/api/hit?url=https%3A%2F%2Fhellpanderrr.github.io%2Fwiktionary_pron%2Fmacronizer.html&label=&icon=github&color=%23198754&message=&style=flat&tz=UTC">
<script>
// File cache: blob URLs for cached WASM/data/model files
window.__fileCacheUrls = {};
// locateFile: serves /wiktionary_pron/macronizer/wasm/ paths
window.Module = window.Module || {};
window.Module.locateFile = function(path) {
if (window.__fileCacheUrls[path]) return window.__fileCacheUrls[path];
if (path.endsWith('.wasm') || path.endsWith('.data') || path.endsWith('.model'))
return '/wiktionary_pron/macronizer/wasm/' + path;
return path;
};
window.Morpheus = window.Morpheus || {};
window.Morpheus.locateFile = window.Module.locateFile;
var HEAVY_FILES = ['rftagger.wasm', 'rftagger-ldt.model', 'cruncher.wasm', 'cruncher.data'];
// v3: assets are now fetched gzipped (~10MB instead of 66MB) and gunzipped in the
// browser. The DECOMPRESSED blob is what gets cached, so return visits are unchanged.
var WASM_CACHE = 'wasm-files-v3';
// A server may serve .gz with Content-Encoding: gzip, in which case the browser has
// already decompressed it — gunzipping again would fail. Check the gzip magic number.
function looksGzipped(buf) {
var b = new Uint8Array(buf, 0, Math.min(2, buf.byteLength));
return b.length > 1 && b[0] === 0x1f && b[1] === 0x8b;
}
async function fetchAsset(path) {
var canGunzip = typeof DecompressionStream === 'function';
if (canGunzip) {
try {
var gz = await fetch(path + '.gz');
if (gz.ok) {
var raw = await gz.arrayBuffer();
if (!looksGzipped(raw)) return new Blob([raw]);
var stream = new Blob([raw]).stream().pipeThrough(new DecompressionStream('gzip'));
return await new Response(stream).blob();
}
} catch (e) { /* fall through to the uncompressed file */ }
}
var plain = await fetch(path);
if (!plain.ok) throw new Error('Failed to fetch ' + path + ': ' + plain.status);
return await plain.blob();
}
// First visit = assets not yet stored. Say so up front: it's a ~50MB one-time download.
window.__ASSETS_READY_KEY = 'macronizer_assets_ready';
window.__firstRun = !localStorage.getItem(window.__ASSETS_READY_KEY);
window.__setupStatus = function (msg) {
var el = document.getElementById('firstRunStatus');
if (el && window.__firstRun) el.textContent = msg;
};
if (window.__firstRun) {
document.getElementById('firstRun').style.display = 'block';
}
var FILE_LABELS = {
'rftagger.wasm': 'POS tagger',
'rftagger-ldt.model': 'tagger model (2 MB)',
'cruncher.wasm': 'morphology engine',
'cruncher.data': 'morphology database (5 MB)'
};
window.__wasmReady = new Promise(function (resolve, reject) {
(async function () {
var cache;
try {
var cacheKeys = await caches.keys();
for (var ck of cacheKeys) {
if (ck.indexOf('wasm-files-') === 0 && ck !== WASM_CACHE)
await caches.delete(ck);
}
cache = await caches.open(WASM_CACHE);
} catch (e) { cache = null; }
for (var i = 0; i < HEAVY_FILES.length; i++) {
var f = HEAVY_FILES[i];
var path = '/wiktionary_pron/macronizer/wasm/' + f;
if (cache) {
var cached = await cache.match(path);
if (cached) {
window.__fileCacheUrls[f] = URL.createObjectURL(await cached.blob());
continue;
}
}
try {
window.__setupStatus('Downloading ' + (FILE_LABELS[f] || f) +
' — ' + (i + 1) + ' of ' + HEAVY_FILES.length + '…');
var blob = await fetchAsset(path); // fetches path + '.gz' and gunzips
window.__fileCacheUrls[f] = URL.createObjectURL(blob);
if (cache) await cache.put(path, new Response(blob)).catch(function (e) {
console.warn('Failed to cache ' + path, e);
});
} catch (e) { console.warn('Failed to cache ' + path, e); }
}
function loadScript(src) {
return new Promise(function (r, j) {
var s = document.createElement('script');
s.src = src;
s.onload = r;
s.onerror = function () { j(new Error('Failed to load ' + src)); };
document.head.appendChild(s);
});
}
try {
await Promise.all([
loadScript('/wiktionary_pron/macronizer/wasm/rftagger.js'),
loadScript('/wiktionary_pron/macronizer/wasm/cruncher.js')
]);
} catch (e) {
window.__setupStatus('Failed to load the analysis engine — check your connection and reload the page.');
reject(e);
return;
}
resolve();
})();
});
</script>
<script type="module">
import { MacronizerAPI } from './macronizer/dist/api/MacronizerAPI.js';
import { decodeLdtTagToFeatures, underscoreToUnicode } from './macronizer/dist/utils/latin.js';
// Orthography options of the last run — wordlist forms use classical j/v and
// must be converted back to i/u unless the user enabled those conversions
let lastOrtho = { utov: false, itoj: false };
// A displayed word mirrors the orthography the USER typed it with: "divisa"
// stays dīvīsa, "diuisa" stays dīuīsa. The i→j / u→v checkboxes still force the
// classical forms regardless of input. Without the input mirror, a word typed
// with v shows its wordlist v form first, the first click converts it to u, and
// the original spelling is unreachable (the divisa/diviso bug).
function applyOrtho(c, inputText) {
if (!(lastOrtho.itoj || (inputText && /[jJ]/.test(inputText)))) c = c.replace(/j/g, 'i').replace(/J/g, 'I');
if (!(lastOrtho.utov || (inputText && /[vV]/.test(inputText)))) c = c.replace(/v/g, 'u').replace(/V/g, 'U');
return c;
}
// wordlist form → text form used for cycling: a_ → ā, breve/ambiguity markers stripped
function candidateToDisplay(c, inputText) {
return applyOrtho(underscoreToUnicode(c).replace(/[\^+_]/g, ''), inputText);
}
// wordlist form → popup form: also render breves (a^ → ă) so candidates that
// differ only in vowel shortness stay distinguishable (me^mo^rem vs me^morem)
const BREVE_MAP = { a:'ă', e:'ĕ', i:'ĭ', o:'ŏ', u:'ŭ', A:'Ă', E:'Ĕ', I:'Ĭ', O:'Ŏ', U:'Ŭ' };
function candidateToPopup(c, inputText) {
return applyOrtho(underscoreToUnicode(c)
.replace(/([a-zA-ZāēīōūȳĀĒĪŌŪȲ])\^/g, (m, v) => BREVE_MAP[v] || (v + '̆'))
.replace(/[+_]/g, ''), inputText);
}
// The ONE writer for a word's display text. Phase 1 of the editing overhaul
// made the output real text (selectable/copyable/findable) while keeping the
// content attribute in sync as the machine-readable source — export, aria-labels
// and the e2e suite all still read it. Every render path must funnel through here.
function setDisplay(span, text) {
span.textContent = text;
span.setAttribute('content', text);
}
// ---- Phase 2 editing: click-vowel toggle + type-to-edit + undo ----
// Winge's correction gesture: click a vowel to toggle its macron (ā↔a, … ȳ↔y, both
// cases). Structurally it can't introduce a non-Latin typo — only Latin vowels move.
const VOWEL_REPL = {
'ā': 'a', 'ē': 'e', 'ī': 'i', 'ō': 'o', 'ū': 'u', 'ȳ': 'y',
'Ā': 'A', 'Ē': 'E', 'Ī': 'I', 'Ō': 'O', 'Ū': 'U', 'Ȳ': 'Y',
'a': 'ā', 'e': 'ē', 'i': 'ī', 'o': 'ō', 'u': 'ū', 'y': 'ȳ',
'A': 'Ā', 'E': 'Ē', 'I': 'Ī', 'O': 'Ō', 'U': 'Ū', 'Y': 'Ȳ'
};
// The char under the pointer. The caret offset from caretRangeFromPoint can sit on
// either side of a glyph (clicking a consonant often yields the *next* char's offset),
// so resolve it to the char whose glyph rect actually contains the click point. That
// way clicking a consonant never toggles a neighbouring vowel by accident.
function charAtClick(e) {
let r = null;
if (document.caretRangeFromPoint) {
r = document.caretRangeFromPoint(e.clientX, e.clientY);
} else if (document.caretPositionFromPoint) {
const p = document.caretPositionFromPoint(e.clientX, e.clientY);
if (p) r = { startContainer: p.offsetNode, startOffset: p.offset };
}
if (!r || !r.startContainer || r.startContainer.nodeType !== Node.TEXT_NODE) return null;
const node = r.startContainer;
const span = node.parentElement;
if (!span || !span.classList || !span.classList.contains('ipa')) return null;
for (const o of [r.startOffset - 1, r.startOffset]) {
if (o < 0 || o >= node.data.length) continue;
const range = document.createRange();
range.setStart(node, o);
range.setEnd(node, o + 1);
const rect = range.getBoundingClientRect();
if (rect.width === 0) continue; // zero-width (combining marks) — not clickable
if (e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom) {
return { node, offset: o, span };
}
}
return null;
}
// One mutation on the undo/redo stacks: a snapshot of the whole rendered result,
// captured BEFORE the change. Cheap (a short text), simple, and covers both the
// programmatic vowel toggles and the browser's own typing.
const MAX_UNDO = 100;
let undoStack = [];
let redoStack = [];
function resultEditableState() {
const out = [];
for (const tr of document.querySelectorAll('#resultText tr.line')) {
const td = tr.querySelector('td.macron-line');
if (!td) { out.push([]); continue; }
const line = [];
for (const node of td.childNodes) {
if (node.nodeType === Node.TEXT_NODE) line.push(['t', node.data]);
else if (node.classList && node.classList.contains('ipa')) line.push(['i', node.textContent]);
else line.push(['o']); // other (verse-foot chip) — left untouched on restore
}
out.push(line);
}
return out;
}
function applyResultState(state) {
const trs = document.querySelectorAll('#resultText tr.line');
if (state.length !== trs.length) return false;
for (let i = 0; i < trs.length; i++) {
const td = trs[i].querySelector('td.macron-line');
if (!td) return false;
const line = state[i];
const nodes = Array.from(td.childNodes);
if (nodes.length !== line.length) return false;
for (let j = 0; j < nodes.length; j++) {
const node = nodes[j];
const kind = line[j][0];
if (kind === 't' && node.nodeType === Node.TEXT_NODE) node.data = line[j][1];
else if (kind === 'i' && node.classList && node.classList.contains('ipa')) setDisplay(node, line[j][1]);
// 'o' entries stay as they are
}
}
return true;
}
function pushUndo() {
undoStack.push(resultEditableState());
if (undoStack.length > MAX_UNDO) undoStack.shift();
redoStack.length = 0;
}
function undo() {
if (!undoStack.length) return;
redoStack.push(resultEditableState());
applyResultState(undoStack.pop());
}
function redo() {
if (!redoStack.length) return;
undoStack.push(resultEditableState());
applyResultState(redoStack.pop());
}
// The result region is ALWAYS editable (Winge's model): each line's cell is
// contenteditable, so typing corrects in place and native selection works.
const resultRegion = document.getElementById('result');
resultRegion.addEventListener('beforeinput', (e) => {
if (e.inputType && /history/.test(e.inputType)) return; // native undo/redo — we own it
if (!e.target.closest || !e.target.closest('td.macron-line')) return;
pushUndo();
});
// After the browser edits text, keep each word's `content` attr (the export / aria
// source) in sync with what is actually rendered.
resultRegion.addEventListener('input', (e) => {
const td = e.target.closest && e.target.closest('td.macron-line');
if (!td) return;
for (const span of td.querySelectorAll('.ipa')) {
if (span.getAttribute('content') !== span.textContent) span.setAttribute('content', span.textContent);
}
});
// Ctrl/Cmd+Z / Shift+Z / Y undo and redo, but only while the focus/selection is in
// the result — the input textarea keeps its native undo.
document.addEventListener('keydown', (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
const k = e.key.toLowerCase();
if (k !== 'z' && k !== 'y') return;
const sel = window.getSelection && window.getSelection();
const inResult = sel && sel.rangeCount && resultRegion.contains(sel.anchorNode);
const aeInResult = document.activeElement && resultRegion.contains(document.activeElement);
if (!inResult && !aeInResult) return;
e.preventDefault();
if (k === 'z' && e.shiftKey) redo();
else if (k === 'z') undo();
else redo();
});
await window.__wasmReady;
const api = new MacronizerAPI();
let initialized = false;
function finishFirstRun() {
if (!window.__firstRun) return;
localStorage.setItem(window.__ASSETS_READY_KEY, '1');
const box = document.getElementById('firstRun');
box.classList.add('done');
document.getElementById('firstRunTitle').textContent = 'Ready — stored in your browser';
document.getElementById('firstRunStatus').textContent =
'Nothing to download next time: later visits start in a few seconds, offline.';
setTimeout(() => { box.style.display = 'none'; }, 6000);
}
async function init() {
showWlProgress('Initializing...', 0);
window.__setupStatus('Preparing the wordlist…');
// Warm the gloss dictionary side-by-side with the wordlist (M-023g.1): the
// glosses.tsv.gz is ~460 KB vs the ~4 MB wordlist and 2.3 MB cruncher, so it
// lands long before the first popup. This removes the old "first hover
// triggers a lazy download, every word hovered before it finishes shows —"
// race; the rebuild-on-load fix stays as a backstop. Fire-and-forget: not
// awaited, so init never blocks on it.
ensureGlosses();
try {
await api.initialize((percent, message) => {
showWlProgress(message, percent);
window.__setupStatus(message + ' (' + Math.round(percent) + '%)');
});
initialized = true;
finishFirstRun();
// If wordlist loaded during init, hide the load machinery entirely
if (api.isWordlistLoaded()) {
document.getElementById('wlPrompt').style.display = 'none';
document.getElementById('loadIndexedDB').style.display = 'none';
document.getElementById('loadMemory').style.display = 'none';
document.getElementById('macronize_btn').disabled = false;
document.getElementById('clearCache').disabled = false;
} else {
document.getElementById('wlPrompt').style.display = 'block';
document.getElementById('macronize_btn').disabled = true;
document.getElementById('loadIndexedDB').disabled = false;
document.getElementById('loadMemory').disabled = false;
}
updateWlInfo();
hideWlProgress();
} catch (err) {
showError('Failed to initialize: ' + err.message);
window.__setupStatus('Setup failed — reload the page to try again.');
hideWlProgress();
}
}
let currentResult = null;
async function macronizeText() {
if (!initialized) { showError('Please wait for initialization...'); return; }
if (!api.isWordlistLoaded()) { showError('Load the wordlist first — click one of the buttons above.'); return; }
// Strip macrons/breves from input — the pipeline expects unmarked text
// (macronized words would otherwise be torn apart and sent to Morpheus as unknowns)
let text = document.getElementById('text_to_macronize').value
.normalize('NFD').replace(/[̄̆]/g, '').normalize('NFC');
// Latin Library / textbook sources tag lines with trailing verse numbers
// ("urbem, 5", "libellum 1.1"). A trailing number is reference noise, not text —
// and by changing the last word's "following segment" it makes the verse
// impossible to scan (the grey box). Strip them, same as the miner's corpus prep.
text = text.split('\n').map(line =>
line.replace(/^\s*\d+(?:[.,]\d+)*\s*$/, '')
.replace(/\s*\d+(?:[.,]\d+)*\s*$/, '')
).join('\n');
if (!text.trim()) { showError('Please enter some text'); return; }
document.getElementById('loading').style.display = 'block';
document.getElementById('result').style.display = 'none';
document.getElementById('error').style.display = 'none';
lastOrtho = {
utov: document.getElementById('utov').checked,
itoj: document.getElementById('itoj').checked
};
const options = {
macronize: document.getElementById('macronize').checked,
alsomaius: document.getElementById('alsomaius').checked,
scan: document.getElementById('scan').value,
performutov: document.getElementById('utov').checked,
performitoj: document.getElementById('itoj').checked
};
const btn = document.getElementById('macronize_btn');
btn.disabled = true;
try {
const chunks = chunkText(text);
const result = await processChunks(chunks, options);
currentResult = result;
displayResult(result);
document.getElementById('export_pdf').disabled = false;
document.getElementById('export_csv').disabled = false;
document.getElementById('export_csv_caret').disabled = false;
} catch (err) {
showError(err.message);
} finally {
btn.disabled = false;
document.getElementById('loading').style.display = 'none';
setProcProgress(0);
}
}
/**
* Split input into line-aligned chunks (~1500 chars) so the pipeline runs in
* bites the main thread can recover between — otherwise a long text freezes
* the UI for the whole run with no progress feedback.
*/
function chunkText(text, maxChars = 1500) {
const lines = text.split('\n');
const chunks = [];
let cur = [];
let len = 0;
for (const line of lines) {
if (cur.length && len + line.length > maxChars) {
chunks.push(cur.join('\n'));
cur = [];
len = 0;
}
cur.push(line);
len += line.length + 1;
}
if (cur.length) chunks.push(cur.join('\n'));
return chunks;
}