File size: 24,964 Bytes
b6a38d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
if FirstLoad then
	g_LipSyncData = {}
end

---
--- Gets the filename for a voice sample based on the given translation ID.
---
--- @param T table The translation object containing the ID.
--- @param seed number An optional seed value for randomizing the voice variation.
--- @return string The filename of the voice sample.
---
function GetVoiceFilename(T, seed)
    if not T then
        return
    end
    local id = TGetID(T)
    if not id then
        return
    end

    if g_VoiceVariations then
        local all_vars = g_VoiceVariations[id] or g_VoiceVariations[tostring(id)]
        if all_vars and #all_vars > 0 then
            local var = 1 + (seed and BraidRandom(seed, #all_vars) or AsyncRand(#all_vars))
            return all_vars[var]
        end
    end

    local not_robot_voice = "CurrentLanguage/Voices/" .. id

    -- if not (Platform.developer and Platform.pc and config.RunUnpacked) then 
    if not config.VoicesTTS or not GetAccountStorageOptionValue("VoicesTTS") then
        return not_robot_voice
    end

    if GetSoundDuration(not_robot_voice) then
        return not_robot_voice
    end

    local robo_voice = "CurrentLanguage/VoicesTTS/" .. id
    if io.exists(robo_voice .. ".opus") then
        return robo_voice
    end
end

-- choose the sound by the translation id of the given text
---
--- Gets the filename for a voice sample based on the given translation ID and actor.
---
--- @param T table The translation object containing the ID.
--- @param actor string The actor for the voice sample (e.g. "male", "female").
--- @param pathName string An optional path name for the voice sample.
--- @return string The filename of the voice sample.
---
function VoiceSampleByText(T, actor, pathName)
    local id = TGetID(T)
    if not id then
        return false
    end

    local sample
    if actor == "male" then
        sample = pathName or "CurrentLanguage/Voices/Male/" .. id
    elseif actor == "female" then
        sample = pathName or "CurrentLanguage/Voices/Female/" .. id
    elseif actor and actor ~= "" and actor ~= "narrator" then
        sample = string.concat(pathName or "CurrentLanguage/Voices/", not pathName and actor or "", "/", id)
    else
        sample = pathName or "CurrentLanguage/Voices/" .. id
    end
    -- so we can play robo_voices in conversations 
    if not io.exists(sample .. ".opus") then
        sample = GetVoiceFilename(T)
    end

    return sample
end

---
--- Generates a voice context string based on the provided field, annotation, voiced property, and annotation property.
---
--- @param field string|table The field(s) to check for voice data.
--- @param annotation string An optional annotation to include in the voice context.
--- @param voiced_prop string An optional property to check if the object has voice data.
--- @param annotation_prop string An optional property to check for additional annotation data.
--- @return function A function that generates the voice context string for a given object.
---
function VoicedContextFromField(field, annotation, voiced_prop, annotation_prop)
    return function(obj, prop_meta, parent)
        if type(field) == "table" then
            for i = 1, #field do
                if obj[field[i]] and obj[field[i]] ~= "" then
                    field = field[i]
                    break
                end
            end
        end
        local extra_annotation = annotation_prop and obj[annotation_prop]
        if IsT(extra_annotation) then
            extra_annotation = _InternalTranslate(extra_annotation)
        end
        if (not voiced_prop or obj[voiced_prop]) and obj[field] then
            if annotation and extra_annotation then
                return string.format("%s %s voice:%s", annotation, extra_annotation, obj[field])
            elseif annotation or extra_annotation then
                return string.format("%s voice:%s", annotation or extra_annotation, obj[field])
            else
                return string.format("voice:%s", obj[field])
            end
        else
            return annotation and extra_annotation and (annotation .. " " .. extra_annotation) or annotation
                       or extra_annotation or ""
        end
    end
end

---
--- Generates localization IDs for the properties of the given object.
---
--- @param obj table The object to generate localization IDs for.
--- @return table The modified object with localization IDs.
---
function GenerateLocalizationIDs(obj)
    if not obj then
        return
    end

    if PropObjHasMember(obj, "GetProperties") then
        local props = obj:GetProperties()
        for i = 1, #props do
            local prop = props[i]
            local id = prop.id
            if prop.translate then
                local value = obj:GetProperty(id)
                if IsT(value) and not obj:IsDefaultPropertyValue(id, prop, value) then
                    assert(not THasArgs(value))
                    obj:SetProperty(id, T {RandomLocId(), TDevModeGetEnglishText(value)})
                end
            end

            local editor = prop.editor
            if editor == "T_list" and not obj:IsDefaultPropertyValue(id, prop, value) then
                local tlist = obj:GetProperty(id)
                for idx, text in ipairs(tlist) do
                    tlist[idx] = T {RandomLocId(), TDevModeGetEnglishText(text)}
                end
            elseif editor == "nested_obj" then
                GenerateLocalizationIDs(obj:GetProperty(id))
            elseif editor == "nested_list" then
                for _, subobj in ipairs(obj:GetProperty(id)) do
                    GenerateLocalizationIDs(subobj)
                end
            end
        end
    end
    for i = 1, #obj do
        GenerateLocalizationIDs(obj[i], true)
    end
    return obj
end

g_TestUIPlatform = false -- win32 uI
-- g_TestUIPlatform = "ps4" -- to test ps4 UI interface
-- g_TestUIPlatform = "ps5" -- to test ps5 UI interface
-- g_TestUIPlatform = "xbox" -- to test xbox UI interface
-- g_TestUIPlatform = "xbox_one" -- to test xbox one UI interface
-- g_TestUIPlatform = "xbox_series" -- to test xbox series UI interface
-- g_TestUIPlatform = "switch" -- to test Switch UI interface

--- button images remap for PlayStation
--- A mapping of Xbox button names to their PlayStation equivalents.
---
--- This table is used to translate button names between the Xbox and PlayStation
--- controller layouts. The keys in this table are the Xbox button names, and the
--- values are the corresponding PlayStation button names.
---
--- For example, the Xbox "ButtonA" button maps to the PlayStation "Circle" button
--- (or "Cross" button if `const.PlayStationEnterBtnCircle` is false).
---
--- @field ButtonA string The PlayStation button that corresponds to the Xbox "ButtonA" button.
--- @field ButtonB string The PlayStation button that corresponds to the Xbox "ButtonB" button.
--- @field ButtonY string The PlayStation button that corresponds to the Xbox "ButtonY" button.
--- @field ButtonX string The PlayStation button that corresponds to the Xbox "ButtonX" button.
--- @field LT string The PlayStation button that corresponds to the Xbox "LT" button.
--- @field RT string The PlayStation button that corresponds to the Xbox "RT" button.
--- @field LS string The PlayStation button that corresponds to the Xbox "LS" button.
--- @field RS string The PlayStation button that corresponds to the Xbox "RS" button.
--- @field LSPress string The PlayStation button that corresponds to the Xbox "LSPress" button.
--- @field RSPress string The PlayStation button that corresponds to the Xbox "RSPress" button.
--- @field LB string The PlayStation button that corresponds to the Xbox "LB" button.
--- @field RB string The PlayStation button that corresponds to the Xbox "RB" button.
--- @field Start string The PlayStation button that corresponds to the Xbox "Start" button.
--- @field Back string The PlayStation button that corresponds to the Xbox "Back" button.
const.XboxToPlayStationButtons = {ButtonA=const.PlayStationEnterBtnCircle and "Circle" or "Cross",
    ButtonB=const.PlayStationEnterBtnCircle and "Cross" or "Circle", ButtonY="Triangle", ButtonX="Square", LT="L2",
    RT="R2", LS="L", RS="R", LSPress="L3", RSPress="R3", LB="L1", RB="R1", Start="Options", Back="TouchPad"}

const.ShortenedButtonNames = {
	LeftTrigger = "LT",
	RightTrigger = "RT",
	LeftShoulder = "LB",
	RightShoulder = "RB",
	RightThumbClick = "RSPress",
	LeftThumbClick = "LSPress",
}

local TagToImageName = {
	["ButtonA"]   = "ButtonA",
	["ButtonB"]   = "ButtonB",
	["ButtonX"]   = "ButtonX",
	["ButtonY"]   = "ButtonY",
	["DPad"]      = "DPad",
	["DPadUp"]    = "DPadUp",
	["DPadDown"]  = "DPadDown",
	["DPadLeft"]  = "DPadLeft",
	["DPadRight"] = "DPadRight",
	["DPadUpDown"] = "DPad_Up_Down",
	["DPadLeftRight"] = "DPad_Left_Right",
	["LT"]        = "LT",
	["RT"]        = "RT",
	["LeftTrigger"] = "LT",
	["RightTrigger"] = "RT",
	["LS"]        = "LS",
	["RS"]        = "RS",
	["LSPress"]   = "LSPress",
	["RSPress"]   = "RSPress",
	["LB"]        = "LB",
	["RB"]        = "RB",
	["Start"]     = "Start",
	["Back"]      = "Back",
	["lsupdown"]  = "ls_up_down",
	["lsright"]   = "lsright",
	["lsleft"]    = "lsleft",
	["lsup"]      = "lsup",
	["lsdown"]    = "lsdown",
	["rsupdown"]  = "rs_up_down",
	["rsright"]   = "rsright",
	["rsleft"]    = "rsleft",
	["rsup"]      = "rsup",
	["rsdown"]    = "rsdown",
	["RightThumbUp"]    = "rsup",
	["RightThumbDown"]    = "rsdown",
	["Controller"] = "Controller",
}


-- these get auto-replaced in translated text, e.g. <A> is the same as <image UI/Xbox/A.tga>
---
--- Recreates the lookup table that maps button tags to their corresponding platform-specific image names.
--- This function is called when the active controller type changes, to ensure the correct button images are displayed.
---
--- @function RecreateButtonsTagLookupTable
--- @return nil
function RecreateButtonsTagLookupTable()
	for tag, img in pairs(TagToImageName) do
		const.TagLookupTable[tag] = GetPlatformSpecificImageTag(img)
	end
end

---
--- Handles updating the active controller type for the PC platform.
--- This function is called when the XInput controller is initialized or the active controller is updated.
--- It updates the `g_PCActiveControllerType` global variable and triggers the `OnControllerTypeChanged` message if the controller type has changed.
---
--- @function UpdateActiveControllerType
--- @return nil
function UpdateActiveControllerType()
end

---
--- Returns the current active controller type for the PC platform.
---
--- @function GetPCActiveControllerType
--- @return string|boolean the active controller type, or `false` if no controller is connected
function GetPCActiveControllerType()
end
if Platform.pc then
    function UpdateActiveControllerType()
        if not rawget(_G, "XInput") or not IsXInputControllerConnected() or not ActiveController then
            return
        end

        local previous = g_PCActiveControllerType
        g_PCActiveControllerType = XInput.GetControllerType(ActiveController) or false
        if g_PCActiveControllerType ~= previous then
            RecreateButtonsTagLookupTable()
            Msg("OnControllerTypeChanged", g_PCActiveControllerType)
        end
    end

    OnMsg.XInputInitialized = UpdateActiveControllerType
    OnMsg.ActiveControllerUpdated = UpdateActiveControllerType

    if FirstLoad then
        g_PCActiveControllerType = false
    end
    function GetPCActiveControllerType()
        return g_PCActiveControllerType
    end
else
    function GetPCActiveControllerType()
    end
end

function ShouldShowPS4Images()
	return Platform.ps4 or g_TestUIPlatform == "ps4" or (not g_TestUIPlatform and GetPCActiveControllerType() == "ps4")
end

function ShouldShowPS5Images()
	return Platform.ps5 or g_TestUIPlatform == "ps5" or (not g_TestUIPlatform and GetPCActiveControllerType() == "ps5")
end

---
--- Returns the platform-specific image name for the given button.
---
--- @param btn string the button name
--- @param platform string the platform name
--- @return string the platform-specific image name
function GetPlatformSpecificImageName(btn, platform)
    btn = const.ShortenedButtonNames[btn] or btn
    local path = "UI/DesktopGamepad/"
    local ext = ".tga"
    local btnimg = btn
    if platform == "ps4" then
        path = "UI/PS4/"
        btnimg = (const.XboxToPlayStationButtons[btn] or btn)
    elseif platform == "ps5" then
        path = "UI/PS5/"
        btnimg = (const.XboxToPlayStationButtons[btn] or btn)
    elseif platform == "xbox_one" then
        path = "UI/XboxOne/"
    elseif platform == "xbox_series" then
        path = "UI/XboxSeries/"
    elseif platform == "switch" then
        path = "UI/Switch/"
    else -- win32
    end
    return path .. btnimg .. ext
end

---
--- Collects the resource IDs for all platform-specific button images used in the game.
---
--- @param resIds table a table to store the resource IDs and their associated DLC
function ReportButtonImageResources(resIds)
    local function AddResourcesForPlatform(platform, dlc)
        dlc = dlc or platform
        for _, btn in pairs(TagToImageName) do
            local name = GetPlatformSpecificImageName(btn, platform)
            local resId = ResourceManager.GetResourceID(name)
            if ResourceManager.GetResourceIDAsString(resId) ~= "" then
                resIds[resId] = dlc
            end
        end
    end

    AddResourcesForPlatform("win32")
    AddResourcesForPlatform("ps4")
    AddResourcesForPlatform("ps5")
    AddResourcesForPlatform("xbox_one", "xbox")
    AddResourcesForPlatform("switch")
end

--- Returns the platform-specific image path and scale for the given button.
---
--- @param btn string the button name
--- @return string the platform-specific image path
--- @return number the scale factor for the image
function GetPlatformSpecificImagePath(btn)
    btn = const.ShortenedButtonNames[btn] or btn
    local path = "UI/DesktopGamepad/"
    local ext = ".tga"
    local btnimg = btn
    if ShouldShowPS4Images() then
        path = "UI/PS4/"
        btnimg = (const.XboxToPlayStationButtons[btn] or btn)
    elseif ShouldShowPS5Images() then
        path = "UI/PS5/"
        btnimg = (const.XboxToPlayStationButtons[btn] or btn)
    elseif Platform.xbox_one then
        path = "UI/XboxOne/"
    elseif Platform.xbox_series then
        path = "UI/XboxSeries/"
    elseif Platform.switch or g_TestUIPlatform == "switch" or GetPCActiveControllerType() == "switch" then
        path = "UI/Switch/"
    else -- win32
    end
    return path .. btnimg .. ext, 500
end

---
--- Returns the platform-specific image tag for the given button.
---
--- @param btn string the button name
--- @param scale number (optional) the scale factor for the image
--- @return string the platform-specific image tag
---
function GetPlatformSpecificImageTag(btn, scale)
    btn = const.ShortenedButtonNames[btn] or btn
    local path = "UI/DesktopGamepad/"
    local btnimg = btn
    if ShouldShowPS4Images() then
        path = "UI/PS4/"
        btnimg = (const.XboxToPlayStationButtons[btn] or btn)
    elseif ShouldShowPS5Images() then
        path = "UI/PS5/"
        btnimg = (const.XboxToPlayStationButtons[btn] or btn)
    elseif Platform.xbox_one then
        path = "UI/XboxOne/"
    elseif Platform.xbox_series then
        path = "UI/XboxSeries/"
    elseif Platform.switch or g_TestUIPlatform == "switch" or GetPCActiveControllerType() == "switch" then
        path = "UI/Switch/"
    else -- win32
    end
    if scale then
        return string.format("<image %s%s.tga %d>", path, btnimg, tonumber(scale) or 1000)
    else
        return string.format("<image %s%s.tga>", path, btnimg)
    end
end

OnMsg.XInputInitialized = RecreateButtonsTagLookupTable

RecreateButtonsTagLookupTable()

-- lookup tags with Untranslated()
const.TagLookupTable["tm"]         = Untranslated("™") -- ( tm TM trademark trade mark ) symbol
const.TagLookupTable["copyright"]  = Untranslated("©") -- ( copyright (c) (C) copy right ) symbol
const.TagLookupTable["registered"] = Untranslated("®") -- ( registered (r) (R) ) symbol
const.TagLookupTable["nbsp"] = Untranslated(" ") -- ( non-breaking space nonbreaking space non breaking space ) symbol

local replace_map = {
	["`"] = "'",
	["‘"] = "'",
	["’"] = "'",
	["“"] = "\"",
	["”"] = "\"",
	["–"] = "-",
	["—"] = "-",
	["−"] = "-",
	["…"] = "...",
}

--- Replaces non-standard characters in the input string with their standard counterparts.
---
--- @param s string The input string to replace non-standard characters in.
--- @return string The input string with non-standard characters replaced.
function ReplaceNonStandardCharacters(s)
    for k, v in pairs(replace_map) do
        s = s:gsub(k, v)
    end
    return s
end

changed = {}

---
--- Fixes up preset text properties by replacing non-standard characters.
---
--- This function iterates through all presets in the `Presets` table, and for each text property
--- that has a non-standard character, it replaces the character with its standard counterpart.
--- It then saves all modified presets to disk.
---
--- @param none
--- @return none
---
function FixupPresetTs()
    local count = 0
    local validation_start = GetPreciseTicks()
    PauseInfiniteLoopDetection("FixupPresetTs")

    local eval = prop_eval
    local dirty = {}
    for class_name, presets in pairs(Presets) do
        for _, group in ipairs(presets) do
            for _, preset in ipairs(group) do
                preset:ForEachSubObject(function(obj)
                    for _, prop in ipairs(obj:GetProperties()) do
                        if prop.editor == "text" and eval(prop.translate, obj, prop) then
                            local t = obj:GetProperty(prop.id)
                            if t and t ~= "" then
                                local id, text = TGetID(t) or RandomLocId(), TDevModeGetEnglishText(t)
                                local new_text = ReplaceNonStandardCharacters(text)
                                if text ~= new_text then
                                    obj:SetProperty(prop.id, T(id, new_text))
                                    table.insert(changed, preset.class .. " " .. new_text)
                                    dirty[class_name] = true
                                    count = count + 1
                                end
                            end
                        end
                    end
                end)
            end
        end
    end

    for class_name in pairs(dirty) do
        _G[class_name]:SaveAll("force save all")
    end

    ResumeInfiniteLoopDetection("FixupPresetTs")
    CreateMessageBox(nil, Untranslated("Fixup Ts"), Untranslated(
        string.format("Changed a total of %d texts for %d ms", count, GetPreciseTicks() - validation_start)))
end

local diacritics_map = {
    ["À"] = "A",
    ["Á"] = "A",
    ["Â"] = "A",
    ["Ã"] = "A",
    ["Ä"] = "A",
    ["Å"] = "A",
    ["Æ"] = "AE",
    ["Ç"] = "C",
    ["È"] = "E",
    ["É"] = "E",
    ["Ê"] = "E",
    ["Ë"] = "E",
    ["Ì"] = "I",
    ["Í"] = "I",
    ["Î"] = "I",
    ["Ï"] = "I",
    ["Ð"] = "D",
    ["Ñ"] = "N",
    ["Ò"] = "O",
    ["Ó"] = "O",
    ["Ô"] = "O",
    ["Õ"] = "O",
    ["Ö"] = "O",
    ["Ø"] = "O",
    ["Ù"] = "U",
    ["Ú"] = "U",
    ["Û"] = "U",
    ["Ü"] = "U",
    ["Ý"] = "Y",
    ["Þ"] = "P",
    ["ß"] = "s",
    ["à"] = "a",
    ["á"] = "a",
    ["â"] = "a",
    ["ã"] = "a",
    ["ä"] = "a",
    ["å"] = "a",
    ["æ"] = "ae",
    ["ç"] = "c",
    ["è"] = "e",
    ["é"] = "e",
    ["ê"] = "e",
    ["ë"] = "e",
    ["ì"] = "i",
    ["í"] = "i",
    ["î"] = "i",
    ["ï"] = "i",
    ["ð"] = "eth",
    ["ñ"] = "n",
    ["ò"] = "o",
    ["ó"] = "o",
    ["ô"] = "o",
    ["õ"] = "o",
    ["ö"] = "o",
    ["ø"] = "o",
    ["ù"] = "u",
    ["ú"] = "u",
    ["û"] = "u",
    ["ü"] = "u",
    ["ý"] = "y",
    ["þ"] = "p",
    ["ÿ"] = "y",
}

---
--- Removes diacritical marks (accents, umlauts, etc.) from the given string.
---
--- @param s string The input string to remove diacritical marks from.
--- @return string The input string with diacritical marks removed.
function RemoveDiacritics(s)
    return s:gsub("[%z\1-\127\194-\244][\128-\191]*", diacritics_map)
end

-------------------- Tokenization -------------------------
tag_processors = {}

DefineClass.XTextToken = {
	__parents = { "PropertyObject" },

	text = false,
	type = false,
	args = false,
}
 
-- Text -> Tokens
---
--- Tokenizes the given input text into a stream of tokens.
---
--- @param input_text string The input text to tokenize.
--- @param token_func function (optional) A custom token processing function. If not provided, a default function will be used.
--- @param stream table (optional) The output stream to append tokens to. If not provided, a new stream will be created.
--- @return table The stream of tokens.
function XTextTokenize(input_text, token_func, stream)
    if not token_func then
        stream = stream or {}
        token_func = function(stream, ttype, args, text)
            if text == "" then
                return
            end
            local next_token = XTextToken:new({type=ttype, text=text, args=args})
            table.insert(stream, next_token)
        end
    end

    if type(input_text) ~= "string" or not utf8.IsValidString(input_text) then
        token_func(stream, "text", false,
            "Not a valid UTF-8 string:" .. string.gsub(input_text, "[^a-zA-Z0-9\n <%>-%:%(%)\\/]", "."))
        return stream
    end

    local byte_idx = 1
    local input_text_bytes_len = #input_text
    local tags_on = true
    while byte_idx <= input_text_bytes_len do
        local start_byte_idx, end_byte_idx = string.find(input_text, "</?[^%s=>][^>]*>", byte_idx)
        if not start_byte_idx then
            start_byte_idx = input_text_bytes_len + 1
        end
        token_func(stream, "text", false, string.sub(input_text, byte_idx, start_byte_idx - 1))
        byte_idx = start_byte_idx + 1

        -- grab any available non text tokens
        if end_byte_idx then
            -- we're done
            byte_idx = end_byte_idx + 1
            local token = string.sub(input_text, start_byte_idx + 1, end_byte_idx - 1)

            local elements
            if token:find("%s") then
                elements = {}
                for part in string.gmatch(token, "[^%s]+") do
                    table.insert(elements, part)
                end

                -- collapse quoted strings
                local i = 1
                while i <= #elements do
                    if elements[i]:starts_with("'") then
                        if i < #elements and not elements[i]:ends_with("'") then
                            elements[i] = string.format("%s %s", elements[i], elements[i + 1])
                            table.remove(elements, i + 1)
                        else
                            if elements[i]:ends_with("'") then
                                elements[i] = elements[i]:sub(2, -2)
                            end
                            i = i + 1
                        end
                    else
                        i = i + 1
                    end
                end
            end

            local tag = elements and elements[1] or token
            if tag == "tags" then
                tags_on = elements and elements[2] == "on"
            elseif tag == "literal" and tonumber(elements and elements[2]) then
                local offset = tonumber(elements[2])
                offset = Min(Max(0, offset), input_text_bytes_len)
                byte_idx = Max(end_byte_idx + 1, Min(input_text_bytes_len + 1, end_byte_idx + offset + 1))
                token_func(stream, "text", false, string.sub(input_text, end_byte_idx + 1, byte_idx - 1))
            elseif token == "" or not tag_processors[tag] or tag == "text" or not tags_on then
                token_func(stream, "text", false, "<" .. token .. ">")
            else
                if elements then
                    table.remove(elements, 1)
                end
                token_func(stream, tag, elements, token)
            end
        end
    end

    return stream
end


local starts_with = string.starts_with
local string_gmatch = string.gmatch
---
--- Counts the number of words in the given line of text.
---
--- @param line string The input line of text to count words in.
--- @return integer The number of words in the input line.
function CountWords(line)
    local count = 0
    for _, span in ipairs(XTextTokenize(line)) do
        if span.type == "text" then
            local text = span.text
            if not starts_with(text, "<") then
                for word in string_gmatch(text, "[^%s]+") do
                    count = count + 1
                end
            end
        end
    end
    return count
end