From 03d7a3453f762ea35d5bc969134fad963d06b90d Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Mon, 14 Sep 2026 12:30:49 +0300 Subject: [PATCH 1/3] feat(text): Add Unicode paragraph direction detection --- .../Source/WWVegas/WW3D2/CMakeLists.txt | 2 + .../WWVegas/WW3D2/supplementarybidi.inl | 265 ++++++++++++++++++ .../Source/WWVegas/WW3D2/unicode-license.txt | 39 +++ .../Source/WWVegas/WW3D2/unicodebidi.h | 84 ++++++ scripts/generate_supplementary_bidi.py | 54 ++++ 5 files changed, 444 insertions(+) create mode 100644 Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl create mode 100644 Core/Libraries/Source/WWVegas/WW3D2/unicode-license.txt create mode 100644 Core/Libraries/Source/WWVegas/WW3D2/unicodebidi.h create mode 100644 scripts/generate_supplementary_bidi.py diff --git a/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt b/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt index 68f59638745..ed8c7be4f63 100644 --- a/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt +++ b/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt @@ -163,6 +163,8 @@ set(WW3D2_SRC #render2d.h render2dsentence.cpp render2dsentence.h + unicodebidi.h + supplementarybidi.inl renderobjectrecycler.cpp renderobjectrecycler.h rendobj.cpp diff --git a/Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl b/Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl new file mode 100644 index 00000000000..317207d8226 --- /dev/null +++ b/Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl @@ -0,0 +1,265 @@ +// Generated by scripts/generate_supplementary_bidi.py; do not edit. +// Unicode 17.0.0 DerivedBidiClass.txt, including its @missing defaults. +// Copyright 2025 Unicode, Inc. See unicode-license.txt. +// Omitted ranges have direction LeftToRight. Neutral means neither L, R nor AL. + { 0x10101, 0x10101, Neutral }, + { 0x10140, 0x1018C, Neutral }, + { 0x10190, 0x1019C, Neutral }, + { 0x101A0, 0x101A0, Neutral }, + { 0x101FD, 0x101FD, Neutral }, + { 0x102E0, 0x102FB, Neutral }, + { 0x10376, 0x1037A, Neutral }, + { 0x10800, 0x1091E, RightToLeft }, + { 0x1091F, 0x1091F, Neutral }, + { 0x10920, 0x10A00, RightToLeft }, + { 0x10A01, 0x10A03, Neutral }, + { 0x10A04, 0x10A04, RightToLeft }, + { 0x10A05, 0x10A06, Neutral }, + { 0x10A07, 0x10A0B, RightToLeft }, + { 0x10A0C, 0x10A0F, Neutral }, + { 0x10A10, 0x10A37, RightToLeft }, + { 0x10A38, 0x10A3A, Neutral }, + { 0x10A3B, 0x10A3E, RightToLeft }, + { 0x10A3F, 0x10A3F, Neutral }, + { 0x10A40, 0x10AE4, RightToLeft }, + { 0x10AE5, 0x10AE6, Neutral }, + { 0x10AE7, 0x10B38, RightToLeft }, + { 0x10B39, 0x10B3F, Neutral }, + { 0x10B40, 0x10D23, RightToLeft }, + { 0x10D24, 0x10D27, Neutral }, + { 0x10D28, 0x10D2F, RightToLeft }, + { 0x10D30, 0x10D39, Neutral }, + { 0x10D3A, 0x10D3F, RightToLeft }, + { 0x10D40, 0x10D49, Neutral }, + { 0x10D4A, 0x10D68, RightToLeft }, + { 0x10D69, 0x10D6E, Neutral }, + { 0x10D6F, 0x10E5F, RightToLeft }, + { 0x10E60, 0x10E7E, Neutral }, + { 0x10E7F, 0x10EAA, RightToLeft }, + { 0x10EAB, 0x10EAC, Neutral }, + { 0x10EAD, 0x10ECF, RightToLeft }, + { 0x10ED0, 0x10ED8, Neutral }, + { 0x10ED9, 0x10EF9, RightToLeft }, + { 0x10EFA, 0x10EFF, Neutral }, + { 0x10F00, 0x10F45, RightToLeft }, + { 0x10F46, 0x10F50, Neutral }, + { 0x10F51, 0x10F81, RightToLeft }, + { 0x10F82, 0x10F85, Neutral }, + { 0x10F86, 0x10FFF, RightToLeft }, + { 0x11001, 0x11001, Neutral }, + { 0x11038, 0x11046, Neutral }, + { 0x11052, 0x11065, Neutral }, + { 0x11070, 0x11070, Neutral }, + { 0x11073, 0x11074, Neutral }, + { 0x1107F, 0x11081, Neutral }, + { 0x110B3, 0x110B6, Neutral }, + { 0x110B9, 0x110BA, Neutral }, + { 0x110C2, 0x110C2, Neutral }, + { 0x11100, 0x11102, Neutral }, + { 0x11127, 0x1112B, Neutral }, + { 0x1112D, 0x11134, Neutral }, + { 0x11173, 0x11173, Neutral }, + { 0x11180, 0x11181, Neutral }, + { 0x111B6, 0x111BE, Neutral }, + { 0x111C9, 0x111CC, Neutral }, + { 0x111CF, 0x111CF, Neutral }, + { 0x1122F, 0x11231, Neutral }, + { 0x11234, 0x11234, Neutral }, + { 0x11236, 0x11237, Neutral }, + { 0x1123E, 0x1123E, Neutral }, + { 0x11241, 0x11241, Neutral }, + { 0x112DF, 0x112DF, Neutral }, + { 0x112E3, 0x112EA, Neutral }, + { 0x11300, 0x11301, Neutral }, + { 0x1133B, 0x1133C, Neutral }, + { 0x11340, 0x11340, Neutral }, + { 0x11366, 0x1136C, Neutral }, + { 0x11370, 0x11374, Neutral }, + { 0x113BB, 0x113C0, Neutral }, + { 0x113CE, 0x113CE, Neutral }, + { 0x113D0, 0x113D0, Neutral }, + { 0x113D2, 0x113D2, Neutral }, + { 0x113E1, 0x113E2, Neutral }, + { 0x11438, 0x1143F, Neutral }, + { 0x11442, 0x11444, Neutral }, + { 0x11446, 0x11446, Neutral }, + { 0x1145E, 0x1145E, Neutral }, + { 0x114B3, 0x114B8, Neutral }, + { 0x114BA, 0x114BA, Neutral }, + { 0x114BF, 0x114C0, Neutral }, + { 0x114C2, 0x114C3, Neutral }, + { 0x115B2, 0x115B5, Neutral }, + { 0x115BC, 0x115BD, Neutral }, + { 0x115BF, 0x115C0, Neutral }, + { 0x115DC, 0x115DD, Neutral }, + { 0x11633, 0x1163A, Neutral }, + { 0x1163D, 0x1163D, Neutral }, + { 0x1163F, 0x11640, Neutral }, + { 0x11660, 0x1166C, Neutral }, + { 0x116AB, 0x116AB, Neutral }, + { 0x116AD, 0x116AD, Neutral }, + { 0x116B0, 0x116B5, Neutral }, + { 0x116B7, 0x116B7, Neutral }, + { 0x1171D, 0x1171D, Neutral }, + { 0x1171F, 0x1171F, Neutral }, + { 0x11722, 0x11725, Neutral }, + { 0x11727, 0x1172B, Neutral }, + { 0x1182F, 0x11837, Neutral }, + { 0x11839, 0x1183A, Neutral }, + { 0x1193B, 0x1193C, Neutral }, + { 0x1193E, 0x1193E, Neutral }, + { 0x11943, 0x11943, Neutral }, + { 0x119D4, 0x119D7, Neutral }, + { 0x119DA, 0x119DB, Neutral }, + { 0x119E0, 0x119E0, Neutral }, + { 0x11A01, 0x11A06, Neutral }, + { 0x11A09, 0x11A0A, Neutral }, + { 0x11A33, 0x11A38, Neutral }, + { 0x11A3B, 0x11A3E, Neutral }, + { 0x11A47, 0x11A47, Neutral }, + { 0x11A51, 0x11A56, Neutral }, + { 0x11A59, 0x11A5B, Neutral }, + { 0x11A8A, 0x11A96, Neutral }, + { 0x11A98, 0x11A99, Neutral }, + { 0x11B60, 0x11B60, Neutral }, + { 0x11B62, 0x11B64, Neutral }, + { 0x11B66, 0x11B66, Neutral }, + { 0x11C30, 0x11C36, Neutral }, + { 0x11C38, 0x11C3D, Neutral }, + { 0x11C92, 0x11CA7, Neutral }, + { 0x11CAA, 0x11CB0, Neutral }, + { 0x11CB2, 0x11CB3, Neutral }, + { 0x11CB5, 0x11CB6, Neutral }, + { 0x11D31, 0x11D36, Neutral }, + { 0x11D3A, 0x11D3A, Neutral }, + { 0x11D3C, 0x11D3D, Neutral }, + { 0x11D3F, 0x11D45, Neutral }, + { 0x11D47, 0x11D47, Neutral }, + { 0x11D90, 0x11D91, Neutral }, + { 0x11D95, 0x11D95, Neutral }, + { 0x11D97, 0x11D97, Neutral }, + { 0x11EF3, 0x11EF4, Neutral }, + { 0x11F00, 0x11F01, Neutral }, + { 0x11F36, 0x11F3A, Neutral }, + { 0x11F40, 0x11F40, Neutral }, + { 0x11F42, 0x11F42, Neutral }, + { 0x11F5A, 0x11F5A, Neutral }, + { 0x11FD5, 0x11FF1, Neutral }, + { 0x13440, 0x13440, Neutral }, + { 0x13447, 0x13455, Neutral }, + { 0x1611E, 0x16129, Neutral }, + { 0x1612D, 0x1612F, Neutral }, + { 0x16AF0, 0x16AF4, Neutral }, + { 0x16B30, 0x16B36, Neutral }, + { 0x16F4F, 0x16F4F, Neutral }, + { 0x16F8F, 0x16F92, Neutral }, + { 0x16FE2, 0x16FE2, Neutral }, + { 0x16FE4, 0x16FE4, Neutral }, + { 0x1BC9D, 0x1BC9E, Neutral }, + { 0x1BCA0, 0x1BCA3, Neutral }, + { 0x1CC00, 0x1CCD5, Neutral }, + { 0x1CCF0, 0x1CCFC, Neutral }, + { 0x1CD00, 0x1CEB3, Neutral }, + { 0x1CEBA, 0x1CED0, Neutral }, + { 0x1CEE0, 0x1CEF0, Neutral }, + { 0x1CF00, 0x1CF2D, Neutral }, + { 0x1CF30, 0x1CF46, Neutral }, + { 0x1D167, 0x1D169, Neutral }, + { 0x1D173, 0x1D182, Neutral }, + { 0x1D185, 0x1D18B, Neutral }, + { 0x1D1AA, 0x1D1AD, Neutral }, + { 0x1D1E9, 0x1D1EA, Neutral }, + { 0x1D200, 0x1D245, Neutral }, + { 0x1D300, 0x1D356, Neutral }, + { 0x1D6C1, 0x1D6C1, Neutral }, + { 0x1D6DB, 0x1D6DB, Neutral }, + { 0x1D6FB, 0x1D6FB, Neutral }, + { 0x1D715, 0x1D715, Neutral }, + { 0x1D735, 0x1D735, Neutral }, + { 0x1D74F, 0x1D74F, Neutral }, + { 0x1D76F, 0x1D76F, Neutral }, + { 0x1D789, 0x1D789, Neutral }, + { 0x1D7A9, 0x1D7A9, Neutral }, + { 0x1D7C3, 0x1D7C3, Neutral }, + { 0x1D7CE, 0x1D7FF, Neutral }, + { 0x1DA00, 0x1DA36, Neutral }, + { 0x1DA3B, 0x1DA6C, Neutral }, + { 0x1DA75, 0x1DA75, Neutral }, + { 0x1DA84, 0x1DA84, Neutral }, + { 0x1DA9B, 0x1DA9F, Neutral }, + { 0x1DAA1, 0x1DAAF, Neutral }, + { 0x1E000, 0x1E006, Neutral }, + { 0x1E008, 0x1E018, Neutral }, + { 0x1E01B, 0x1E021, Neutral }, + { 0x1E023, 0x1E024, Neutral }, + { 0x1E026, 0x1E02A, Neutral }, + { 0x1E08F, 0x1E08F, Neutral }, + { 0x1E130, 0x1E136, Neutral }, + { 0x1E2AE, 0x1E2AE, Neutral }, + { 0x1E2EC, 0x1E2EF, Neutral }, + { 0x1E2FF, 0x1E2FF, Neutral }, + { 0x1E4EC, 0x1E4EF, Neutral }, + { 0x1E5EE, 0x1E5EF, Neutral }, + { 0x1E6E3, 0x1E6E3, Neutral }, + { 0x1E6E6, 0x1E6E6, Neutral }, + { 0x1E6EE, 0x1E6EF, Neutral }, + { 0x1E6F5, 0x1E6F5, Neutral }, + { 0x1E800, 0x1E8CF, RightToLeft }, + { 0x1E8D0, 0x1E8D6, Neutral }, + { 0x1E8D7, 0x1E943, RightToLeft }, + { 0x1E944, 0x1E94A, Neutral }, + { 0x1E94B, 0x1EEEF, RightToLeft }, + { 0x1EEF0, 0x1EEF1, Neutral }, + { 0x1EEF2, 0x1EFFF, RightToLeft }, + { 0x1F000, 0x1F02B, Neutral }, + { 0x1F030, 0x1F093, Neutral }, + { 0x1F0A0, 0x1F0AE, Neutral }, + { 0x1F0B1, 0x1F0BF, Neutral }, + { 0x1F0C1, 0x1F0CF, Neutral }, + { 0x1F0D1, 0x1F0F5, Neutral }, + { 0x1F100, 0x1F10F, Neutral }, + { 0x1F12F, 0x1F12F, Neutral }, + { 0x1F16A, 0x1F16F, Neutral }, + { 0x1F1AD, 0x1F1AD, Neutral }, + { 0x1F260, 0x1F265, Neutral }, + { 0x1F300, 0x1F6D8, Neutral }, + { 0x1F6DC, 0x1F6EC, Neutral }, + { 0x1F6F0, 0x1F6FC, Neutral }, + { 0x1F700, 0x1F7D9, Neutral }, + { 0x1F7E0, 0x1F7EB, Neutral }, + { 0x1F7F0, 0x1F7F0, Neutral }, + { 0x1F800, 0x1F80B, Neutral }, + { 0x1F810, 0x1F847, Neutral }, + { 0x1F850, 0x1F859, Neutral }, + { 0x1F860, 0x1F887, Neutral }, + { 0x1F890, 0x1F8AD, Neutral }, + { 0x1F8B0, 0x1F8BB, Neutral }, + { 0x1F8C0, 0x1F8C1, Neutral }, + { 0x1F8D0, 0x1F8D8, Neutral }, + { 0x1F900, 0x1FA57, Neutral }, + { 0x1FA60, 0x1FA6D, Neutral }, + { 0x1FA70, 0x1FA7C, Neutral }, + { 0x1FA80, 0x1FA8A, Neutral }, + { 0x1FA8E, 0x1FAC6, Neutral }, + { 0x1FAC8, 0x1FAC8, Neutral }, + { 0x1FACD, 0x1FADC, Neutral }, + { 0x1FADF, 0x1FAEA, Neutral }, + { 0x1FAEF, 0x1FAF8, Neutral }, + { 0x1FB00, 0x1FB92, Neutral }, + { 0x1FB94, 0x1FBFA, Neutral }, + { 0x1FFFE, 0x1FFFF, Neutral }, + { 0x2FFFE, 0x2FFFF, Neutral }, + { 0x3FFFE, 0x3FFFF, Neutral }, + { 0x4FFFE, 0x4FFFF, Neutral }, + { 0x5FFFE, 0x5FFFF, Neutral }, + { 0x6FFFE, 0x6FFFF, Neutral }, + { 0x7FFFE, 0x7FFFF, Neutral }, + { 0x8FFFE, 0x8FFFF, Neutral }, + { 0x9FFFE, 0x9FFFF, Neutral }, + { 0xAFFFE, 0xAFFFF, Neutral }, + { 0xBFFFE, 0xBFFFF, Neutral }, + { 0xCFFFE, 0xCFFFF, Neutral }, + { 0xDFFFE, 0xE0FFF, Neutral }, + { 0xEFFFE, 0xEFFFF, Neutral }, + { 0xFFFFE, 0xFFFFF, Neutral }, + { 0x10FFFE, 0x10FFFF, Neutral }, diff --git a/Core/Libraries/Source/WWVegas/WW3D2/unicode-license.txt b/Core/Libraries/Source/WWVegas/WW3D2/unicode-license.txt new file mode 100644 index 00000000000..56da5891280 --- /dev/null +++ b/Core/Libraries/Source/WWVegas/WW3D2/unicode-license.txt @@ -0,0 +1,39 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2026 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/Core/Libraries/Source/WWVegas/WW3D2/unicodebidi.h b/Core/Libraries/Source/WWVegas/WW3D2/unicodebidi.h new file mode 100644 index 00000000000..b9d1dfcd306 --- /dev/null +++ b/Core/Libraries/Source/WWVegas/WW3D2/unicodebidi.h @@ -0,0 +1,84 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2026 TheSuperHackers +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#pragma once + +#include "WWLib/win.h" + +namespace UnicodeBidi +{ + +enum Direction { Neutral, LeftToRight, RightToLeft }; + +inline Direction Get_Supplementary_Direction(unsigned codepoint) +{ + struct Range { unsigned First, Last; Direction Value; }; + static const Range ranges[] = { +#include "supplementarybidi.inl" + }; + unsigned first = 0, last = sizeof(ranges) / sizeof(ranges[0]); + while (first < last) { + const unsigned middle = first + (last - first) / 2; + if (codepoint < ranges[middle].First) { + last = middle; + } else if (codepoint > ranges[middle].Last) { + first = middle + 1; + } else { + return ranges[middle].Value; + } + } + return LeftToRight; +} + +// TheSuperHackers @bugfix Omar Aglan 13/09/2026 Determine paragraph direction from complete UTF-16 +// characters. GetStringTypeW does not classify supplementary characters reliably. +inline WORD Get_Paragraph_Level(const WCHAR *text, int length) +{ + int isolate_depth = 0; + for (int index = 0; index < length; ++index) { + const WCHAR ch = text[index]; + if (ch >= 0x2066 && ch <= 0x2068) { + ++isolate_depth; + continue; + } + if (ch == 0x2069) { + if (isolate_depth > 0) --isolate_depth; + continue; + } + // UAX #9 P2 excludes the contents of directional isolates. + if (isolate_depth > 0) continue; + + Direction direction = Neutral; + if (ch >= 0xD800 && ch <= 0xDBFF) { + if (index + 1 < length && text[index + 1] >= 0xDC00 && text[index + 1] <= 0xDFFF) { + const unsigned codepoint = 0x10000 + ((ch - 0xD800) << 10) + (text[++index] - 0xDC00); + direction = Get_Supplementary_Direction(codepoint); + } + } else if (ch < 0xDC00 || ch > 0xDFFF) { + WORD type = C2_NOTAPPLICABLE; + if (::GetStringTypeW(CT_CTYPE2, &ch, 1, &type)) { + if (type == C2_LEFTTORIGHT) direction = LeftToRight; + if (type == C2_RIGHTTOLEFT) direction = RightToLeft; + } + } + if (direction != Neutral) return direction == RightToLeft ? 1 : 0; + } + return 0; +} + +} diff --git a/scripts/generate_supplementary_bidi.py b/scripts/generate_supplementary_bidi.py new file mode 100644 index 00000000000..e5e779c64b6 --- /dev/null +++ b/scripts/generate_supplementary_bidi.py @@ -0,0 +1,54 @@ +"""Generate the supplementary first-strong-direction table from Unicode 17.0.0. + +Input: https://www.unicode.org/Public/17.0.0/ucd/extracted/DerivedBidiClass.txt +Usage: python scripts/generate_supplementary_bidi.py DerivedBidiClass.txt [--check] +""" + +import argparse +import hashlib +from pathlib import Path +import re + + +def generate(source): + if not source.startswith("# DerivedBidiClass-17.0.0.txt"): + raise ValueError("Expected Unicode 17.0.0 DerivedBidiClass.txt") + if hashlib.sha256(source.encode("utf-8")).hexdigest() != "4867b4b7f0731ed1bfcd34cc6251211ff1542541fce0734b6fbda139ee80b3a4": + raise ValueError("Unexpected Unicode data checksum") + classes = ["L"] * 0x110000 + aliases = {"Left_To_Right": "L", "Right_To_Left": "R", "Arabic_Letter": "AL"} + missing = re.findall(r"@missing: ([0-9A-F]+)\.\.([0-9A-F]+); (\w+)", source) + explicit = re.findall(r"^([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s*;\s*(\w+)", source, re.M) + for lo, hi, value in missing + explicit: + lo, hi = int(lo, 16), int(hi or lo, 16) + classes[lo:hi + 1] = [aliases.get(value, value)] * (hi - lo + 1) + directions = ["LeftToRight" if c == "L" else "RightToLeft" if c in ("R", "AL") + else "Neutral" for c in classes] + lines = [ + "// Generated by scripts/generate_supplementary_bidi.py; do not edit.", + "// Unicode 17.0.0 DerivedBidiClass.txt, including its @missing defaults.", + "// Copyright 2025 Unicode, Inc. See unicode-license.txt.", + "// Omitted ranges have direction LeftToRight. Neutral means neither L, R nor AL.", + ] + start = 0x10000 + for end in range(start + 1, len(directions) + 1): + if end == len(directions) or directions[end] != directions[start]: + if directions[start] != "LeftToRight": + lines.append("\t{ 0x%05X, 0x%05X, %s }," % (start, end - 1, directions[start])) + start = end + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + output = Path(__file__).resolve().parents[1] / "Core/Libraries/Source/WWVegas/WW3D2/supplementarybidi.inl" + generated = generate(args.source.read_text(encoding="utf-8")) + if args.check: + if output.read_text(encoding="utf-8") != generated: + raise SystemExit("Supplementary bidi table is out of date") + print("Unicode 17.0.0 supplementary bidi table matches") + else: + output.write_text(generated, encoding="utf-8") From 45a680a2c2f382c28f1667b45d8ade80bda37df7 Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Sun, 13 Sep 2026 12:55:52 +0300 Subject: [PATCH 2/3] chore(lib): Define Uniscribe itemization control --- Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h b/Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h index 3b35b03b82b..2ac7646b75d 100644 --- a/Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h +++ b/Core/Libraries/Source/WWVegas/WWLib/Usp10Loader.h @@ -29,9 +29,16 @@ class Usp10Loader public: typedef void *ScriptStringAnalysis; - struct ScriptControl; struct ScriptTabDefinition; + // TheSuperHackers @info Omar Aglan 13/09/2026 ScriptItemize needs a non-null control + // structure for bidirectional analysis. Keep the unused control flags zero. + struct ScriptControl + { + DWORD default_language : 16; + DWORD reserved : 16; + }; + struct ScriptState { WORD bidi_level : 5; From 779818a818b56299af0f5f8186fc5b4f96d47ea4 Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Mon, 14 Sep 2026 12:31:06 +0300 Subject: [PATCH 3/3] feat(text): Add complex text shaping and rasterization --- .../Source/WWVegas/WW3D2/CMakeLists.txt | 1 + .../Source/WWVegas/WW3D2/complextext.h | 228 ++++++++++++++++++ .../Source/WWVegas/WW3D2/render2dsentence.cpp | 155 +++++++++++- .../Source/WWVegas/WW3D2/render2dsentence.h | 5 +- Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp | 6 + 5 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 Core/Libraries/Source/WWVegas/WW3D2/complextext.h diff --git a/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt b/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt index ed8c7be4f63..6e8d52b0fb5 100644 --- a/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt +++ b/Core/Libraries/Source/WWVegas/WW3D2/CMakeLists.txt @@ -163,6 +163,7 @@ set(WW3D2_SRC #render2d.h render2dsentence.cpp render2dsentence.h + complextext.h unicodebidi.h supplementarybidi.inl renderobjectrecycler.cpp diff --git a/Core/Libraries/Source/WWVegas/WW3D2/complextext.h b/Core/Libraries/Source/WWVegas/WW3D2/complextext.h new file mode 100644 index 00000000000..0cb3583eef5 --- /dev/null +++ b/Core/Libraries/Source/WWVegas/WW3D2/complextext.h @@ -0,0 +1,228 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2026 TheSuperHackers +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program. If not, see . +*/ + +#pragma once + +#include "WWLib/Usp10Loader.h" +#include "unicodebidi.h" +#include +#include +#include +#include + +struct ComplexTextRun +{ + ComplexTextRun () : + Analysis(nullptr), + Font(nullptr), + CharacterPosition(0), + CharacterCount(0), + Width(0), + Ascent(0) + { + ::memset(&State, 0, sizeof(State)); + } + + Usp10Loader::ScriptStringAnalysis Analysis; + HFONT Font; + int CharacterPosition; + int CharacterCount; + int Width; + int Ascent; + Usp10Loader::ScriptState State; +}; + + +struct ComplexTextLayout +{ + ComplexTextLayout (HDC source_dc) : + DC(::CreateCompatibleDC(source_dc)), + Width(0), Height(0), Ascent(0), Descent(0) + { + } + + ~ComplexTextLayout () + { + for (size_t index = 0; index < Runs.size(); ++index) { + if (Runs[index].Analysis != nullptr) { + Usp10Loader::ScriptStringFree(&Runs[index].Analysis); + } + } + + if (DC != nullptr) { + ::DeleteDC(DC); + } + } + + HDC DC; + std::vector Runs; + std::vector VisualToLogical; + int Width; + int Height; + int Ascent; + int Descent; + +private: + ComplexTextLayout (const ComplexTextLayout &); + ComplexTextLayout &operator= (const ComplexTextLayout &); +}; + + +static bool Uses_Alternate_Unicode_Font (const WCHAR *text, int character_position, HFONT alternate_font) +{ + return alternate_font != nullptr && text[character_position] >= 256; +} + + +static bool Build_Complex_Text_Layout (HDC dc, const WCHAR *text, int text_length, + HFONT primary_font, HFONT alternate_font, ComplexTextLayout *layout) +{ + if (dc == nullptr || text == nullptr || text_length <= 0 || + text_length > (INT_MAX - 16) / 3 || primary_font == nullptr) + { + return false; + } + + if (alternate_font == primary_font) { + alternate_font = nullptr; + } + + // TheSuperHackers @bugfix Omar Aglan 13/09/2026 Preserve paragraph order for mixed RTL text. + // ScriptItemize needs both control and state for full bidirectional analysis. + Usp10Loader::ScriptControl control = { 0 }; + Usp10Loader::ScriptState initial_state = { 0 }; + initial_state.bidi_level = UnicodeBidi::Get_Paragraph_Level(text, text_length); + + std::vector items(text_length + 2); + int item_count = 0; + if (Usp10Loader::ScriptItemize(text, text_length, text_length + 1, &control, &initial_state, &items[0], &item_count) != S_OK || + item_count <= 0) + { + return false; + } + + std::vector attributes(text_length); + for (int break_item_index = 0; break_item_index < item_count; ++break_item_index) { + const int item_start = items[break_item_index].character_position; + const int item_length = items[break_item_index + 1].character_position - item_start; + if (Usp10Loader::ScriptBreak(text + item_start, item_length, &items[break_item_index].analysis, + &attributes[item_start]) != S_OK) + { + return false; + } + } + + layout->Runs.reserve(text_length); + for (int run_item_index = 0; run_item_index < item_count; ++run_item_index) { + const int item_end = items[run_item_index + 1].character_position; + int run_start = items[run_item_index].character_position; + bool uses_alternate_font = Uses_Alternate_Unicode_Font(text, run_start, alternate_font); + for (int run_end = run_start + 1; run_end <= item_end; ++run_end) { + const bool run_ends = run_end == item_end || + (attributes[run_end].char_stop && + uses_alternate_font != Uses_Alternate_Unicode_Font(text, run_end, alternate_font)); + if (run_ends) { + layout->Runs.push_back(ComplexTextRun()); + ComplexTextRun &run = layout->Runs.back(); + run.Font = uses_alternate_font ? alternate_font : primary_font; + run.CharacterPosition = run_start; + run.CharacterCount = run_end - run_start; + run.State = items[run_item_index].analysis.state; + run_start = run_end; + if (run_end < item_end) { + uses_alternate_font = Uses_Alternate_Unicode_Font(text, run_end, alternate_font); + } + } + } + } + const int run_count = (int)layout->Runs.size(); + layout->VisualToLogical.resize(run_count); + + std::vector bidi_levels(run_count); + HFONT old_font = (HFONT)::GetCurrentObject(dc, OBJ_FONT); + bool success = true; + for (int index = 0; index < run_count; ++index) { + ComplexTextRun &run = layout->Runs[index]; + if (::SelectObject(dc, run.Font) == nullptr) { + success = false; + break; + } + + bidi_levels[index] = (BYTE)run.State.bidi_level; + const int glyph_count = run.CharacterCount + run.CharacterCount / 2 + 16; + DWORD flags = Usp10Loader::SSA_GLYPHS | Usp10Loader::SSA_FALLBACK; + if ((run.State.bidi_level & 1) != 0) { + flags |= Usp10Loader::SSA_RTL; + } + + // TheSuperHackers @bugfix Omar Aglan 13/09/2026 Keep directional overrides inside font runs. + const HRESULT analysis_result = Usp10Loader::ScriptStringAnalyse(dc, + text + run.CharacterPosition, run.CharacterCount, glyph_count, -1, flags, 0, + &control, &run.State, nullptr, nullptr, nullptr, &run.Analysis); + const SIZE *run_size = analysis_result == S_OK ? Usp10Loader::ScriptString_pSize(run.Analysis) : nullptr; + TEXTMETRIC text_metrics = { 0 }; + if (run_size == nullptr || run_size->cx < 0 || run_size->cx > INT_MAX - layout->Width || + run_size->cy <= 0 || !::GetTextMetrics(dc, &text_metrics)) + { + success = false; + break; + } + + run.Width = run_size->cx; + run.Ascent = (std::min)((int)text_metrics.tmAscent, (int)run_size->cy); + layout->Width += run.Width; + layout->Ascent = (std::max)(layout->Ascent, run.Ascent); + layout->Descent = (std::max)(layout->Descent, (int)run_size->cy - run.Ascent); + } + if (layout->Ascent > INT_MAX - layout->Descent) { + success = false; + } else { + layout->Height = layout->Ascent + layout->Descent; + } + + if (success) { + success = Usp10Loader::ScriptLayout(run_count, &bidi_levels[0], &layout->VisualToLogical[0], nullptr) == S_OK && + layout->Width > 0 && layout->Height > 0; + } + + ::SelectObject(dc, old_font); + return success; +} + + +static bool Draw_Complex_Text_Layout (ComplexTextLayout &layout) +{ + HFONT old_font = (HFONT)::GetCurrentObject(layout.DC, OBJ_FONT); + int x = 0; + bool success = true; + for (size_t visual_index = 0; visual_index < layout.Runs.size(); ++visual_index) { + ComplexTextRun &run = layout.Runs[layout.VisualToLogical[visual_index]]; + if (::SelectObject(layout.DC, run.Font) == nullptr || + Usp10Loader::ScriptStringOut(run.Analysis, x, layout.Ascent - run.Ascent, + 0, nullptr, 0, 0, FALSE) != S_OK) + { + success = false; + break; + } + x += run.Width; + } + ::SelectObject(layout.DC, old_font); + + // TheSuperHackers @bugfix Omar Aglan 13/09/2026 Complete GDI drawing before the bitmap is read or freed. + return ::GdiFlush() != FALSE && success; +} diff --git a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp index 29d1a22a82d..4a563325665 100644 --- a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp +++ b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.cpp @@ -40,6 +40,9 @@ #include "WWDebug/wwprofile.h" #include "WWDebug/wwmemlog.h" #include "dx8wrapper.h" +#if defined(_WIN32) +#include "complextext.h" +#endif //////////////////////////////////////////////////////////////////////////////////// @@ -48,6 +51,13 @@ #define no_TEST_PLACEMENT 1 // Shows alignment markers for text. #define TEXTURE_OFFSET 2 + +static inline uint16 Convert_Font_Pixel (uint8 intensity) +{ + const uint16 color = intensity == 0 ? 0 : 0x0FFF; + return color | ((intensity >> 4) << 12); +} + //////////////////////////////////////////////////////////////////////////////////// // // Render2DSentenceClass @@ -1269,6 +1279,26 @@ FontCharsClass::Get_Char_Spacing (WCHAR ch) } +//////////////////////////////////////////////////////////////////////////////////// +// +// Is_Complex_Text +// +//////////////////////////////////////////////////////////////////////////////////// +bool +FontCharsClass::Is_Complex_Text (const WCHAR *text) +{ +#if defined(_WIN32) + if (text == nullptr || text[0] == 0) { + return false; + } + + return Usp10Loader::ScriptIsComplex(text, (int)wcslen(text), Usp10Loader::SIC_COMPLEX) == S_OK; +#else + return false; +#endif +} + + //////////////////////////////////////////////////////////////////////////////////// // // Blit_Char @@ -1305,6 +1335,122 @@ FontCharsClass::Blit_Char (WCHAR ch, uint16 *dest_ptr, int dest_stride, int x, i } +//////////////////////////////////////////////////////////////////////////////////// +// +// Build_Complex_Text +// +//////////////////////////////////////////////////////////////////////////////////// +bool +FontCharsClass::Build_Complex_Text (const WCHAR *text, int *width, int *height, + float maximum_width, int maximum_height, uint16 **raster) +{ + if (width == nullptr || height == nullptr) { + return false; + } + + if (raster != nullptr) { + *raster = nullptr; + } + *width = 0; + *height = 0; + +#if defined(_WIN32) + const size_t text_length = text == nullptr ? 0 : wcslen(text); + if (text_length == 0 || text_length > (INT_MAX - 16) / 3 || MemDC == nullptr) { + return false; + } + + ComplexTextLayout layout(MemDC); + HDC text_dc = layout.DC; + if (text_dc == nullptr) { + return false; + } + + ::SetBkColor(text_dc, RGB(0, 0, 0)); + ::SetTextColor(text_dc, RGB(255, 255, 255)); + ::SetBkMode(text_dc, TRANSPARENT); + + // TheSuperHackers @bugfix Omar Aglan 02/09/2026 Preserve the primary font for + // Latin runs and use the configured Unicode font for the remaining runs. + HFONT alternate_font = AlternateUnicodeFont != nullptr && AlternateUnicodeFont != this ? + AlternateUnicodeFont->GDIFont : nullptr; + if (!Build_Complex_Text_Layout(text_dc, text, (int)text_length, GDIFont, alternate_font, &layout)) { + return false; + } + + const int text_width = layout.Width; + const int text_height = layout.Height; + if ((maximum_width > 0 && text_width >= maximum_width) || + (maximum_height > 0 && text_height >= maximum_height)) + { + return false; + } + + if (raster == nullptr) { + *width = text_width; + *height = text_height; + return true; + } + + if (text_width > (INT_MAX - 3) / 3) { + return false; + } + const int bitmap_stride = ((text_width * 3) + 3) & ~3; + if (text_height > INT_MAX / bitmap_stride) { + return false; + } + + BITMAPINFO bitmap_info = { 0 }; + bitmap_info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmap_info.bmiHeader.biWidth = text_width; + bitmap_info.bmiHeader.biHeight = -text_height; + bitmap_info.bmiHeader.biPlanes = 1; + bitmap_info.bmiHeader.biBitCount = 24; + bitmap_info.bmiHeader.biCompression = BI_RGB; + + uint8 *bitmap_bits = nullptr; + HBITMAP bitmap = ::CreateDIBSection(MemDC, &bitmap_info, DIB_RGB_COLORS, + (void **)&bitmap_bits, nullptr, 0L); + if (bitmap == nullptr || bitmap_bits == nullptr) { + if (bitmap != nullptr) { + ::DeleteObject(bitmap); + } + return false; + } + + HBITMAP old_bitmap = (HBITMAP)::SelectObject(text_dc, bitmap); + if (old_bitmap == nullptr) { + ::DeleteObject(bitmap); + return false; + } + + ::memset(bitmap_bits, 0, bitmap_stride * text_height); + + const bool success = Draw_Complex_Text_Layout(layout); + + if (success) { + uint16 *pixels = W3DNEWARRAY uint16[text_width * text_height]; + for (int row = 0; row < text_height; ++row) { + const uint8 *source = bitmap_bits + row * bitmap_stride; + uint16 *destination = pixels + row * text_width; + for (int column = 0; column < text_width; ++column) { + destination[column] = Convert_Font_Pixel(source[column * 3]); + } + } + *raster = pixels; + *width = text_width; + *height = text_height; + } + + ::SelectObject(text_dc, old_bitmap); + ::DeleteObject(bitmap); + return success; +#else + return false; +#endif +} + + //////////////////////////////////////////////////////////////////////////////////// // // Store_GDI_Char @@ -1390,17 +1536,11 @@ FontCharsClass::Store_GDI_Char (WCHAR ch) } #endif - uint16 pixel_color = 0; - if (pixel_value != 0) { - pixel_color = 0x0FFF; - } - // // Convert the pixel intensity from 8bit to 4bit and // store it in our buffer // - uint8 alpha_value = ((pixel_value >> 4) & 0xF); - *curr_buffer_p++ = pixel_color | (alpha_value << 12); + *curr_buffer_p++ = Convert_Font_Pixel(pixel_value); } } @@ -1564,7 +1704,6 @@ FontCharsClass::Create_GDI_Font (const char *font_name) TEXTMETRIC text_metric = { 0 }; ::GetTextMetrics (MemDC, &text_metric); CharHeight = text_metric.tmHeight; - CharAscent = text_metric.tmAscent; CharOverhang = text_metric.tmOverhang; if (doingGenerals) { CharOverhang = 0; diff --git a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.h b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.h index a6c19625cfb..d55f7b195cd 100644 --- a/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.h +++ b/Core/Libraries/Source/WWVegas/WW3D2/render2dsentence.h @@ -95,6 +95,10 @@ class FontCharsClass : public RefCountClass int Get_Char_Height() { return CharHeight; } int Get_Char_Width( WCHAR ch ); int Get_Char_Spacing( WCHAR ch ); + bool Is_Complex_Text( const WCHAR *text ); + // A null raster requests measurement only; both paths apply the same size limits. + bool Build_Complex_Text( const WCHAR *text, int *width, int *height, + float maximum_width, int maximum_height, uint16 **raster = nullptr ); int Get_Extra_Overlap() {return PixelOverlap;} @@ -121,7 +125,6 @@ class FontCharsClass : public RefCountClass DynamicVectorClass BufferList; int CurrPixelOffset; int CharHeight; - int CharAscent; int CharOverhang; int PixelOverlap; int PointSize; diff --git a/Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp b/Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp index c43588d279d..0acf0831f86 100644 --- a/Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp +++ b/Core/Libraries/Source/WWVegas/WW3D2/ww3d.cpp @@ -115,6 +115,9 @@ #include "sortingrenderer.h" #include "WWLib/thread.h" #include "WWLib/cpudetect.h" +#if defined(_WIN32) +#include "WWLib/Usp10Loader.h" +#endif #include "dx8texman.h" #include "animatedsoundmgr.h" #include "static_sort_list.h" @@ -384,6 +387,9 @@ WW3DErrorType WW3D::Shutdown() ** Release the animation-triggered sound data */ AnimatedSoundMgrClass::Shutdown (); +#if defined(_WIN32) + Usp10Loader::unload(); +#endif IsInitted = false; return WW3D_ERROR_OK;