xbintsc 0.3.69 → 0.3.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +57 -4
  2. package/README.zh-CN.md +51 -2
  3. package/dist/src/driver/compiler.js +6 -1
  4. package/dist/src/driver/compiler.js.map +1 -1
  5. package/dist/src/extensions/gui/index.js +10 -2
  6. package/dist/src/extensions/gui/index.js.map +1 -1
  7. package/dist/tests/e2e/gui-advanced.test.d.ts +6 -0
  8. package/dist/tests/e2e/gui-advanced.test.js +261 -0
  9. package/dist/tests/e2e/gui-advanced.test.js.map +1 -0
  10. package/dist/tests/e2e/gui-canvas.test.d.ts +9 -0
  11. package/dist/tests/e2e/gui-canvas.test.js +233 -0
  12. package/dist/tests/e2e/gui-canvas.test.js.map +1 -0
  13. package/dist/tests/e2e/gui-example-svg.test.d.ts +21 -0
  14. package/dist/tests/e2e/gui-example-svg.test.js +105 -0
  15. package/dist/tests/e2e/gui-example-svg.test.js.map +1 -0
  16. package/dist/tests/e2e/gui-example.test.d.ts +1 -1
  17. package/dist/tests/e2e/gui-example.test.js +2 -2
  18. package/dist/tests/e2e/gui-helpers.d.ts +3 -0
  19. package/dist/tests/e2e/gui-helpers.js +5 -0
  20. package/dist/tests/e2e/gui-helpers.js.map +1 -1
  21. package/dist/tests/e2e/gui-layout.test.js +73 -0
  22. package/dist/tests/e2e/gui-layout.test.js.map +1 -1
  23. package/dist/tests/e2e/gui-media.test.d.ts +8 -0
  24. package/dist/tests/e2e/gui-media.test.js +119 -0
  25. package/dist/tests/e2e/gui-media.test.js.map +1 -0
  26. package/dist/tests/e2e/gui-paint.test.js +40 -0
  27. package/dist/tests/e2e/gui-paint.test.js.map +1 -1
  28. package/dist/tests/e2e/gui-transform.test.d.ts +7 -0
  29. package/dist/tests/e2e/gui-transform.test.js +198 -0
  30. package/dist/tests/e2e/gui-transform.test.js.map +1 -0
  31. package/doc/ai/cli.md +5 -0
  32. package/doc/ai/extensions.md +20 -3
  33. package/doc/ai/zh-CN/extensions.md +14 -3
  34. package/doc/gui/animation.md +97 -0
  35. package/doc/gui/api.md +92 -0
  36. package/doc/gui/build.md +91 -0
  37. package/doc/gui/canvas.md +98 -0
  38. package/doc/gui/dom.md +128 -0
  39. package/doc/gui/input.md +25 -0
  40. package/doc/gui/known-issues.md +78 -0
  41. package/doc/gui/layout.md +108 -0
  42. package/doc/gui/media.md +87 -0
  43. package/doc/gui/rendering.md +113 -0
  44. package/doc/gui/runtime.md +88 -0
  45. package/doc/gui-scripts.md +7 -3
  46. package/doc/gui.md +109 -612
  47. package/doc/implemented.md +1 -0
  48. package/doc/zh-CN/gui/animation.md +93 -0
  49. package/doc/zh-CN/gui/api.md +88 -0
  50. package/doc/zh-CN/gui/build.md +84 -0
  51. package/doc/zh-CN/gui/canvas.md +80 -0
  52. package/doc/zh-CN/gui/dom.md +119 -0
  53. package/doc/zh-CN/gui/input.md +23 -0
  54. package/doc/zh-CN/gui/known-issues.md +60 -0
  55. package/doc/zh-CN/gui/layout.md +91 -0
  56. package/doc/zh-CN/gui/media.md +71 -0
  57. package/doc/zh-CN/gui/rendering.md +92 -0
  58. package/doc/zh-CN/gui/runtime.md +76 -0
  59. package/doc/zh-CN/gui-scripts.md +6 -2
  60. package/doc/zh-CN/gui.md +89 -532
  61. package/doc/zh-CN/implemented.md +1 -0
  62. package/llms.txt +15 -2
  63. package/package.json +1 -1
  64. package/runtime/ext_gui/canvas.cpp +580 -0
  65. package/runtime/ext_gui/canvas.h +170 -0
  66. package/runtime/ext_gui/canvas_internal.h +59 -0
  67. package/runtime/ext_gui/canvas_raster.cpp +254 -0
  68. package/runtime/ext_gui/document.cpp +45 -4
  69. package/runtime/ext_gui/document.h +15 -1
  70. package/runtime/ext_gui/dom_api.cpp +3 -0
  71. package/runtime/ext_gui/dom_api_canvas.cpp +548 -0
  72. package/runtime/ext_gui/dom_api_internal.h +73 -0
  73. package/runtime/ext_gui/dom_api_media.cpp +274 -0
  74. package/runtime/ext_gui/dom_api_node.cpp +6 -4
  75. package/runtime/ext_gui/dom_api_proto.cpp +80 -0
  76. package/runtime/ext_gui/gui.cpp +6 -0
  77. package/runtime/ext_gui/gui_canvas.cpp +95 -0
  78. package/runtime/ext_gui/gui_engine.h +49 -0
  79. package/runtime/ext_gui/gui_events.cpp +21 -5
  80. package/runtime/ext_gui/gui_media.cpp +193 -0
  81. package/runtime/ext_gui/image.cpp +31 -12
  82. package/runtime/ext_gui/image.h +20 -7
  83. package/runtime/ext_gui/layout.cpp +130 -6
  84. package/runtime/ext_gui/layout.h +54 -1
  85. package/runtime/ext_gui/layout_flex.cpp +263 -99
  86. package/runtime/ext_gui/layout_flow.cpp +85 -9
  87. package/runtime/ext_gui/layout_flow_internal.h +84 -5
  88. package/runtime/ext_gui/layout_inline.cpp +54 -37
  89. package/runtime/ext_gui/media.cpp +514 -0
  90. package/runtime/ext_gui/media.h +104 -0
  91. package/runtime/ext_gui/nanosvg.h +3278 -0
  92. package/runtime/ext_gui/nanosvgrast.h +1472 -0
  93. package/runtime/ext_gui/paint.cpp +156 -38
  94. package/runtime/ext_gui/paint.h +42 -6
  95. package/runtime/ext_gui/renderer.cpp +117 -12
  96. package/runtime/ext_gui/renderer.h +49 -9
  97. package/runtime/ext_gui/renderer_image.cpp +205 -78
  98. package/runtime/ext_gui/renderer_internal.h +12 -9
  99. package/runtime/ext_gui/renderer_text.cpp +46 -39
  100. package/runtime/ext_gui/style.h +21 -0
  101. package/runtime/ext_gui/style_print.cpp +17 -0
  102. package/runtime/ext_gui/style_ua.cpp +7 -0
  103. package/runtime/ext_gui/style_values.cpp +83 -1
  104. package/runtime/ext_gui/svg.cpp +182 -0
  105. package/runtime/ext_gui/svg.h +46 -0
  106. package/runtime/ext_gui/text.cpp +156 -25
  107. package/runtime/ext_gui/text.h +11 -2
  108. package/runtime/ext_gui/transform.cpp +412 -0
  109. package/runtime/ext_gui/transform.h +112 -0
  110. package/runtime/ext_gui/window.cpp +10 -5
  111. package/scripts/build-gui.ts +148 -6
  112. package/scripts/check-file-length.ts +5 -1
  113. package/scripts/install-gui-deps.sh +28 -0
  114. package/src/driver/compiler.ts +6 -1
  115. package/src/extensions/gui/index.ts +10 -2
@@ -347,6 +347,23 @@ void xt_style_apply_declaration(XtStyle *style, const std::string &property,
347
347
  if (parseEnum(value, names, 4, &index)) style->flex_direction = (FlexDirection)index;
348
348
  return;
349
349
  }
350
+ if (property == "flex-wrap") {
351
+ static const char *const names[] = {"nowrap", "wrap", "wrap-reverse"};
352
+ int index = 0;
353
+ if (parseEnum(value, names, 3, &index)) style->flex_wrap = (FlexWrap)index;
354
+ return;
355
+ }
356
+ if (property == "flex-flow") {
357
+ /* `flex-flow: <direction> || <wrap>` in either order. */
358
+ for (const std::string &token : xt_css_split_whitespace(value)) {
359
+ static const char *const dirs[] = {"row", "row-reverse", "column", "column-reverse"};
360
+ static const char *const wraps[] = {"nowrap", "wrap", "wrap-reverse"};
361
+ int index = 0;
362
+ if (parseEnum(token, dirs, 4, &index)) style->flex_direction = (FlexDirection)index;
363
+ else if (parseEnum(token, wraps, 3, &index)) style->flex_wrap = (FlexWrap)index;
364
+ }
365
+ return;
366
+ }
350
367
  if (property == "justify-content") {
351
368
  static const char *const names[] = {"flex-start", "flex-end", "center", "space-between",
352
369
  "space-around", "space-evenly"};
@@ -360,10 +377,20 @@ void xt_style_apply_declaration(XtStyle *style, const std::string &property,
360
377
  if (parseEnum(value, names, 5, &index)) style->align_items = (AlignItems)index;
361
378
  return;
362
379
  }
363
- if (property == "gap" || property == "row-gap" || property == "column-gap") {
380
+ if (property == "align-content") {
381
+ static const char *const names[] = {"stretch", "flex-start", "flex-end", "center",
382
+ "space-between", "space-around"};
383
+ int index = 0;
384
+ if (parseEnum(value, names, 6, &index)) style->align_content = (AlignContent)index;
385
+ return;
386
+ }
387
+ if (property == "gap") {
364
388
  assignLength(&style->gap, value);
389
+ style->row_gap = style->column_gap = style->gap;
365
390
  return;
366
391
  }
392
+ if (property == "row-gap") { assignLength(&style->row_gap, value); return; }
393
+ if (property == "column-gap") { assignLength(&style->column_gap, value); return; }
367
394
  if (property == "flex-grow") {
368
395
  float number = 0;
369
396
  if (xt_css_parse_number(value, &number)) style->flex_grow = number;
@@ -414,6 +441,49 @@ void xt_style_apply_declaration(XtStyle *style, const std::string &property,
414
441
  return;
415
442
  }
416
443
 
444
+ if (property == "transform") {
445
+ std::vector<TransformOp> ops;
446
+ if (xt_transform_parse(value, &ops)) style->transform = ops;
447
+ return;
448
+ }
449
+ if (property == "transform-origin") {
450
+ /* One or two values plus an optional z offset (ignored: the engine is 2D).
451
+ * The keywords carry an axis, so `top` alone means `center top` and
452
+ * `top center` is the same as `center top`. */
453
+ std::vector<std::string> parts = xt_css_split_whitespace(value);
454
+ Length values[2] = {Length{50.0f, Unit::Percent}, Length{50.0f, Unit::Percent}};
455
+ bool vertical[2] = {false, false};
456
+ size_t used = 0;
457
+ for (const std::string &part : parts) {
458
+ if (used >= 2) break;
459
+ std::string keyword = xt_css_lower(part);
460
+ Length length = xt_css_parse_length(keyword);
461
+ if (length.unit == Unit::Invalid || length.unit == Unit::Auto || length.unit == Unit::None) {
462
+ if (keyword == "left" || keyword == "top") length = {0.0f, Unit::Percent};
463
+ else if (keyword == "center") length = {50.0f, Unit::Percent};
464
+ else if (keyword == "right" || keyword == "bottom") length = {100.0f, Unit::Percent};
465
+ else return;
466
+ vertical[used] = keyword == "top" || keyword == "bottom";
467
+ }
468
+ values[used++] = length;
469
+ }
470
+ if (used == 0) return;
471
+ Length x = values[0];
472
+ Length y = used > 1 ? values[1] : Length{50.0f, Unit::Percent};
473
+ if (used > 1) {
474
+ if (vertical[0] && !vertical[1]) {
475
+ x = values[1];
476
+ y = values[0];
477
+ }
478
+ } else if (vertical[0]) {
479
+ /* `top`/`bottom` on their own keep x centred. */
480
+ x = Length{50.0f, Unit::Percent};
481
+ y = values[0];
482
+ }
483
+ style->transform_origin_x = x;
484
+ style->transform_origin_y = y;
485
+ return;
486
+ }
417
487
  if (property == "opacity") {
418
488
  float number = 0;
419
489
  if (xt_css_parse_number(value, &number)) style->opacity = number < 0 ? 0 : (number > 1 ? 1 : number);
@@ -433,6 +503,18 @@ void xt_style_apply_declaration(XtStyle *style, const std::string &property,
433
503
  }
434
504
  return;
435
505
  }
506
+ if (property == "float") {
507
+ static const char *const names[] = {"none", "left", "right"};
508
+ int index = 0;
509
+ if (parseEnum(value, names, 3, &index)) style->float_side = (FloatSide)index;
510
+ return;
511
+ }
512
+ if (property == "clear") {
513
+ static const char *const names[] = {"none", "left", "right", "both"};
514
+ int index = 0;
515
+ if (parseEnum(value, names, 4, &index)) style->clear = (Clear)index;
516
+ return;
517
+ }
436
518
  if (property == "transition") {
437
519
  parseTransitionShorthand(value, &style->transitions);
438
520
  return;
@@ -0,0 +1,182 @@
1
+ /*
2
+ * xbintsc GUI engine — SVG decoding. See `svg.h` for the contract.
3
+ *
4
+ * The heavy lifting is the vendored NanoSVG library (`nanosvg.h` +
5
+ * `nanosvgrast.h`, zlib licence, fetched verbatim). This file is only the glue:
6
+ * path detection, a parsed-document cache and the scale-to-fit call. NanoSVG
7
+ * emits straight (non-premultiplied) RGBA8, which is exactly the `Image`
8
+ * layout the rest of the engine expects.
9
+ */
10
+
11
+ #include "svg.h"
12
+
13
+ #define NANOSVG_IMPLEMENTATION
14
+ #define NANOSVGRAST_IMPLEMENTATION
15
+ /* NanoSVG is vendored verbatim, so silence the one warning it raises under
16
+ * `-Wall` instead of patching the upstream sources. */
17
+ #if defined(__clang__)
18
+ #pragma clang diagnostic push
19
+ #pragma clang diagnostic ignored "-Wsign-compare"
20
+ #elif defined(__GNUC__)
21
+ #pragma GCC diagnostic push
22
+ #pragma GCC diagnostic ignored "-Wsign-compare"
23
+ #endif
24
+ #include "nanosvg.h"
25
+ #include "nanosvgrast.h"
26
+ #if defined(__clang__)
27
+ #pragma clang diagnostic pop
28
+ #elif defined(__GNUC__)
29
+ #pragma GCC diagnostic pop
30
+ #endif
31
+
32
+ #include <algorithm>
33
+ #include <cctype>
34
+ #include <cstdio>
35
+ #include <fstream>
36
+ #include <string>
37
+ #include <unordered_map>
38
+
39
+ namespace xtgui {
40
+ namespace {
41
+
42
+ /** NanoSVG's default when the SVG declares neither width/height nor a viewBox
43
+ * (SVG 1.1 says a replaced element falls back to 300x150). */
44
+ constexpr float kDefaultWidth = 300.0f;
45
+ constexpr float kDefaultHeight = 150.0f;
46
+ /** Refuse to allocate a monstrous texture if an `<img>` is scaled absurdly. */
47
+ constexpr int kMaxDimension = 8192;
48
+
49
+ std::string lowercase(std::string value) {
50
+ for (char &c : value) c = (char)std::tolower((unsigned char)c);
51
+ return value;
52
+ }
53
+
54
+ /** True when the file name ends in `.svg` (case-insensitive). */
55
+ bool hasSvgExtension(const std::string &path) {
56
+ if (path.size() < 4) return false;
57
+ return lowercase(path.substr(path.size() - 4)) == ".svg";
58
+ }
59
+
60
+ /** Cheap content sniff for a file whose extension is not enough (no extension,
61
+ * or a generated temporary). Looks for `<svg` in the first chunk, which is
62
+ * present after any XML declaration, doctype or leading comment of a sane
63
+ * size. */
64
+ bool prefixLooksLikeSvg(const std::string &path) {
65
+ std::ifstream in(path, std::ios::binary);
66
+ if (!in) return false;
67
+ char buffer[1024];
68
+ in.read(buffer, sizeof(buffer));
69
+ std::streamsize size = in.gcount();
70
+ if (size <= 0) return false;
71
+ std::string prefix(buffer, (size_t)size);
72
+ return prefix.find("<svg") != std::string::npos;
73
+ }
74
+
75
+ std::unordered_map<std::string, NSVGimage *> &parsedCache() {
76
+ static std::unordered_map<std::string, NSVGimage *> cache;
77
+ return cache;
78
+ }
79
+
80
+ /** Remember the extension/sniff verdict so a raster image is not reopened on
81
+ * every frame (the loader asks for every `<img>` each time it paints). */
82
+ std::unordered_map<std::string, bool> &detectCache() {
83
+ static std::unordered_map<std::string, bool> cache;
84
+ return cache;
85
+ }
86
+
87
+ NSVGrasterizer *&rasterizer() {
88
+ static NSVGrasterizer *value = nullptr;
89
+ return value;
90
+ }
91
+
92
+ /** Parse `path` once and remember the result (including failures, so a missing
93
+ * file is not retried every frame). */
94
+ NSVGimage *parseCached(const std::string &path) {
95
+ auto &cache = parsedCache();
96
+ auto found = cache.find(path);
97
+ if (found != cache.end()) return found->second;
98
+ NSVGimage *image = nsvgParseFromFile(path.c_str(), "px", 96.0f);
99
+ cache.emplace(path, image);
100
+ return image;
101
+ }
102
+
103
+ float intrinsicWidth(const NSVGimage *image) {
104
+ return image->width > 0.0f ? image->width : kDefaultWidth;
105
+ }
106
+
107
+ float intrinsicHeight(const NSVGimage *image) {
108
+ return image->height > 0.0f ? image->height : kDefaultHeight;
109
+ }
110
+
111
+ } // namespace
112
+
113
+ bool xt_svg_is_svg(const std::string &path) {
114
+ auto &cache = detectCache();
115
+ auto found = cache.find(path);
116
+ if (found != cache.end()) return found->second;
117
+ bool result = hasSvgExtension(path) || prefixLooksLikeSvg(path);
118
+ cache.emplace(path, result);
119
+ return result;
120
+ }
121
+
122
+ bool xt_svg_size(const std::string &path, int *width, int *height) {
123
+ NSVGimage *image = parseCached(path);
124
+ if (image == nullptr) return false;
125
+ float w = intrinsicWidth(image);
126
+ float h = intrinsicHeight(image);
127
+ if (w <= 0.0f || h <= 0.0f) return false;
128
+ if (width != nullptr) *width = (int)(w + 0.5f);
129
+ if (height != nullptr) *height = (int)(h + 0.5f);
130
+ return true;
131
+ }
132
+
133
+ bool xt_svg_render(const std::string &path, int width, int height, Image *out) {
134
+ if (out == nullptr) return false;
135
+ NSVGimage *image = parseCached(path);
136
+ if (image == nullptr) return false;
137
+
138
+ float source_width = intrinsicWidth(image);
139
+ float source_height = intrinsicHeight(image);
140
+ int target_width = width > 0 ? width : (int)(source_width + 0.5f);
141
+ int target_height = height > 0 ? height : (int)(source_height + 0.5f);
142
+ if (target_width <= 0 || target_height <= 0) return false;
143
+ target_width = std::min(target_width, kMaxDimension);
144
+ target_height = std::min(target_height, kMaxDimension);
145
+
146
+ /* Rasterise at the source aspect ratio, at the larger of the two axis scales
147
+ * (so neither axis is under-sampled), then let the quad stretch the texture
148
+ * to the box — exactly how a bitmap would behave under CSS `object-fit:
149
+ * fill`. The common case (layout preserved the aspect ratio) makes this equal
150
+ * to the requested size. */
151
+ float scale = std::max((float)target_width / source_width, (float)target_height / source_height);
152
+ scale = std::min(scale, (float)kMaxDimension / source_width);
153
+ scale = std::min(scale, (float)kMaxDimension / source_height);
154
+ int raster_width = (int)(source_width * scale + 0.5f);
155
+ int raster_height = (int)(source_height * scale + 0.5f);
156
+ if (raster_width < 1 || raster_height < 1) return false;
157
+
158
+ NSVGrasterizer *&rast = rasterizer();
159
+ if (rast == nullptr) rast = nsvgCreateRasterizer();
160
+ if (rast == nullptr) return false;
161
+
162
+ out->width = raster_width;
163
+ out->height = raster_height;
164
+ out->pixels.assign((size_t)raster_width * (size_t)raster_height * 4u, 0);
165
+ nsvgRasterize(rast, image, 0.0f, 0.0f, scale, out->pixels.data(), raster_width, raster_height,
166
+ raster_width * 4);
167
+ return true;
168
+ }
169
+
170
+ void xt_svg_shutdown() {
171
+ for (auto &entry : parsedCache()) {
172
+ if (entry.second != nullptr) nsvgDelete(entry.second);
173
+ }
174
+ parsedCache().clear();
175
+ detectCache().clear();
176
+ if (rasterizer() != nullptr) {
177
+ nsvgDeleteRasterizer(rasterizer());
178
+ rasterizer() = nullptr;
179
+ }
180
+ }
181
+
182
+ } // namespace xtgui
@@ -0,0 +1,46 @@
1
+ /*
2
+ * xbintsc GUI engine — SVG decoding (NanoSVG wrapper).
3
+ *
4
+ * An SVG file is not a raster, so `stb_image` cannot decode it. This module
5
+ * sits next to `image.{h,cpp}` and turns an SVG file into the same RGBA8
6
+ * `Image` the rest of the image pipeline already consumes: parse once (cached
7
+ * per path) and rasterise into a texture-sized buffer on demand. The parse and
8
+ * the anti-aliased scanline rasteriser are the vendored NanoSVG library
9
+ * (`nanosvg.h` + `nanosvgrast.h`) — the same class of low-level, third-party
10
+ * image library `stb_image` is (see doc/gui.md).
11
+ *
12
+ * Only *files* are handled: `<img src="logo.svg">`. Inline `<svg>` trees in a
13
+ * document are still ordinary unknown elements to the HTML parser and generate
14
+ * no boxes.
15
+ */
16
+ #ifndef XT_GUI_SVG_H
17
+ #define XT_GUI_SVG_H
18
+
19
+ #include <string>
20
+
21
+ #include "image.h"
22
+
23
+ namespace xtgui {
24
+
25
+ /** True when `path` names an SVG file: a `.svg` extension (case-insensitive),
26
+ * or a small prefix of the contents that looks like an SVG root element. */
27
+ bool xt_svg_is_svg(const std::string &path);
28
+
29
+ /** Read the intrinsic size from an SVG file without rasterising it. Returns
30
+ * false when the file is missing or cannot be parsed. Cached per path. */
31
+ bool xt_svg_size(const std::string &path, int *width, int *height);
32
+
33
+ /** Rasterise `path` to RGBA8. `width`/`height` are the requested pixel size;
34
+ * when either is `<= 0` the intrinsic SVG size is used. The viewBox is scaled
35
+ * uniformly at the larger of the two axis scales (preserving the SVG's aspect
36
+ * ratio, so the caller's quad may stretch it exactly as it would a bitmap) and
37
+ * clamped so neither axis exceeds 8192px. Returns false on parse/rasteriser
38
+ * failure. The parse is cached per path; each rasterisation is fresh. */
39
+ bool xt_svg_render(const std::string &path, int width, int height, Image *out);
40
+
41
+ /** Drop the parsed-document cache (called on engine shutdown). */
42
+ void xt_svg_shutdown();
43
+
44
+ } // namespace xtgui
45
+
46
+ #endif /* XT_GUI_SVG_H */
@@ -4,6 +4,15 @@
4
4
  * HarfBuzz does shaping (ligatures/kerning/complex scripts), FreeType owns the
5
5
  * glyph outlines and the vertical metrics. We create one HarfBuzz font per
6
6
  * (file, pixel size) and scale it so that advances come back in 26.6 pixels.
7
+ *
8
+ * A run is shaped with a *single* face, but that face rarely covers every
9
+ * codepoint in it — a CJK face such as Microsoft YaHei / PingFang / Noto Sans
10
+ * CJK has no geometric shapes (U+25AE ▮, U+25AF ▯, …), and a Latin face has no
11
+ * CJK. Each codepoint therefore resolves to the first face in a small fallback
12
+ * chain that has it, the run is shaped in segments (one per face), and each
13
+ * shaped glyph carries the face it came from so the rasteriser draws it from
14
+ * the right atlas entry. Without this a missing glyph came back as `.notdef`
15
+ * (glyph 0) and the renderer silently dropped it.
7
16
  */
8
17
 
9
18
  #include "text.h"
@@ -22,6 +31,7 @@
22
31
  #include <memory>
23
32
  #include <string>
24
33
  #include <unordered_map>
34
+ #include <vector>
25
35
 
26
36
  namespace xtgui {
27
37
 
@@ -30,6 +40,8 @@ namespace xtgui {
30
40
  struct Font {
31
41
  FT_Face ft_face = nullptr;
32
42
  hb_font_t *hb_font = nullptr;
43
+ std::string path;
44
+ bool monospace = false;
33
45
  float pixel_size = 16.0f;
34
46
  float ascent = 0;
35
47
  float descent = 0;
@@ -85,13 +97,33 @@ const char *const kMonoCandidates[] = {
85
97
  "C:\\Windows\\Fonts\\cour.ttf",
86
98
  };
87
99
 
100
+ /* Faces tried after the primary one for codepoints it does not cover. Symbol
101
+ * faces come first (they carry the geometric shapes, arrows, box drawing and
102
+ * dingbats the CJK/Latin primary often lacks), then the other family class so
103
+ * a monospace run still gets CJK and a sans run still gets monospace-only
104
+ * codepoints. The primary and duplicates are dropped when the chain is built. */
105
+ const char *const kSymbolCandidates[] = {
106
+ nullptr,
107
+ "C:\\Windows\\Fonts\\seguisym.ttf", /* Segoe UI Symbol */
108
+ "C:\\Windows\\Fonts\\arialuni.ttf", /* Arial Unicode MS */
109
+ "/System/Library/Fonts/Apple Symbols.ttf",
110
+ "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
111
+ "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf",
112
+ "/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf",
113
+ "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf",
114
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
115
+ };
116
+
88
117
  struct Engine {
89
118
  bool initialized = false;
90
119
  FT_Library ft = nullptr;
91
120
  bool ft_ok = false;
92
121
  std::string sans_path;
93
122
  std::string mono_path;
123
+ std::vector<std::string> sans_fallbacks;
124
+ std::vector<std::string> mono_fallbacks;
94
125
  std::unordered_map<std::string, std::unique_ptr<Font>> cache; // key -> face (may be null)
126
+ std::unordered_map<std::string, std::vector<Font *>> fallbacks; // class+size -> faces
95
127
  std::unordered_map<std::string, hb_face_t *> hb_faces; // path -> face
96
128
  };
97
129
 
@@ -182,6 +214,25 @@ const char *firstExisting(const char *const *candidates, size_t count) {
182
214
  return nullptr;
183
215
  }
184
216
 
217
+ void addFallbackPath(std::vector<std::string> &paths, const char *path) {
218
+ if (path == nullptr || !fileExists(path)) return;
219
+ for (const std::string &entry : paths) {
220
+ if (entry == path) return;
221
+ }
222
+ paths.push_back(path);
223
+ }
224
+
225
+ /* The chain for a primary face: every existing symbol face, then the other
226
+ * family class (CJK coverage for a monospace primary and vice versa). The
227
+ * primary itself is skipped because it is tried first, per codepoint. */
228
+ void buildFallbackChain(std::vector<std::string> &paths, const std::string &primary,
229
+ const std::string &otherClass) {
230
+ for (const char *candidate : kSymbolCandidates) {
231
+ if (candidate != nullptr && primary != candidate) addFallbackPath(paths, candidate);
232
+ }
233
+ if (!otherClass.empty() && otherClass != primary) addFallbackPath(paths, otherClass.c_str());
234
+ }
235
+
185
236
  void ensureInitialized() {
186
237
  Engine &e = engine();
187
238
  if (e.initialized) return;
@@ -214,6 +265,9 @@ void ensureInitialized() {
214
265
  }
215
266
  /* No dedicated monospace face: reuse the sans face so metrics stay real. */
216
267
  if (e.mono_path.empty()) e.mono_path = e.sans_path;
268
+
269
+ buildFallbackChain(e.sans_fallbacks, e.sans_path, e.mono_path);
270
+ buildFallbackChain(e.mono_fallbacks, e.mono_path, e.sans_path);
217
271
  }
218
272
 
219
273
  hb_face_t *hbFaceForPath(const std::string &path) {
@@ -230,7 +284,7 @@ hb_face_t *hbFaceForPath(const std::string &path) {
230
284
  return face;
231
285
  }
232
286
 
233
- std::unique_ptr<Font> createFont(const std::string &path, float pixel_size) {
287
+ std::unique_ptr<Font> createFont(const std::string &path, float pixel_size, bool monospace) {
234
288
  Engine &e = engine();
235
289
  if (!e.ft_ok || path.empty()) return nullptr;
236
290
 
@@ -240,6 +294,8 @@ std::unique_ptr<Font> createFont(const std::string &path, float pixel_size) {
240
294
 
241
295
  auto font = std::make_unique<Font>();
242
296
  font->ft_face = face;
297
+ font->path = path;
298
+ font->monospace = monospace;
243
299
  font->pixel_size = pixel_size;
244
300
 
245
301
  FT_Size_Metrics metrics = face->size->metrics;
@@ -259,43 +315,63 @@ std::unique_ptr<Font> createFont(const std::string &path, float pixel_size) {
259
315
  return font;
260
316
  }
261
317
 
262
- } // namespace
263
-
264
- Font *xt_text_resolve(const FontSpec &spec) {
265
- ensureInitialized();
318
+ /* Resolve one face by path, keyed by path+class+size so the primary resolved
319
+ * through a `FontSpec` and the same face resolved as a fallback share one
320
+ * `Font` (and one glyph-atlas key). */
321
+ Font *resolveFacePath(const std::string &path, float pixel_size, bool monospace) {
266
322
  Engine &e = engine();
267
- if (!e.ft_ok) return nullptr;
268
-
269
- bool mono = isMonospaceFamily(spec.family);
270
- const std::string &path = mono ? e.mono_path : e.sans_path;
271
- if (path.empty()) return nullptr;
272
-
273
- char key[80];
274
- std::snprintf(key, sizeof(key), "%c|%.2f", mono ? 'm' : 's', (double)spec.pixel_size);
323
+ if (!e.ft_ok || path.empty()) return nullptr;
324
+ char key[512];
325
+ std::snprintf(key, sizeof(key), "p|%c|%.2f|%s", monospace ? 'm' : 's', (double)pixel_size,
326
+ path.c_str());
275
327
  auto it = e.cache.find(key);
276
328
  if (it != e.cache.end()) return it->second.get();
277
-
278
- std::unique_ptr<Font> font = createFont(path, spec.pixel_size);
329
+ std::unique_ptr<Font> font = createFont(path, pixel_size, monospace);
279
330
  Font *raw = font.get();
280
331
  e.cache.emplace(key, std::move(font));
281
332
  return raw;
282
333
  }
283
334
 
284
- float xt_text_measure_width(const std::string &utf8, const FontSpec &spec) {
285
- Font *font = xt_text_resolve(spec);
286
- if (font == nullptr) return approximateWidth(utf8, spec.pixel_size, spec.family);
287
- std::vector<ShapedGlyph> glyphs;
288
- return xt_text_shape_run(utf8, font, glyphs);
335
+ /* The resolved fallback faces for a primary, in order, cached per class+size. */
336
+ const std::vector<Font *> &fallbackFaces(Font *font) {
337
+ static const std::vector<Font *> kEmpty;
338
+ if (font == nullptr) return kEmpty;
339
+ Engine &e = engine();
340
+ char key[80];
341
+ std::snprintf(key, sizeof(key), "f|%c|%.2f", font->monospace ? 'm' : 's',
342
+ (double)font->pixel_size);
343
+ auto it = e.fallbacks.find(key);
344
+ if (it != e.fallbacks.end()) return it->second;
345
+
346
+ const std::vector<std::string> &paths =
347
+ font->monospace ? e.mono_fallbacks : e.sans_fallbacks;
348
+ std::vector<Font *> faces;
349
+ for (const std::string &path : paths) {
350
+ Font *face = resolveFacePath(path, font->pixel_size, font->monospace);
351
+ if (face != nullptr) faces.push_back(face);
352
+ }
353
+ return e.fallbacks.emplace(key, std::move(faces)).first->second;
289
354
  }
290
355
 
291
- float xt_text_shape_run(const std::string &utf8, Font *font, std::vector<ShapedGlyph> &out) {
292
- if (font == nullptr) return 0;
356
+ /* The face that draws `code_point`: the primary when it has the glyph, else the
357
+ * first fallback that does, else the primary (so `.notdef` is still skipped). */
358
+ Font *faceForCodePoint(Font *primary, uint32_t code_point) {
359
+ if (primary != nullptr && FT_Get_Char_Index(primary->ft_face, code_point) != 0) return primary;
360
+ for (Font *fallback : fallbackFaces(primary)) {
361
+ if (FT_Get_Char_Index(fallback->ft_face, code_point) != 0) return fallback;
362
+ }
363
+ return primary;
364
+ }
365
+
366
+ /* Shape one face's segment of a run and append its glyphs. */
367
+ float shapeSegment(const std::string &segment, Font *font, std::vector<ShapedGlyph> &out) {
368
+ if (font == nullptr || segment.empty()) return 0;
293
369
  float total = 0;
294
370
  if (font->hb_font == nullptr) {
295
371
  /* HarfBuzz unavailable: fall back to FreeType's per-codepoint advances. */
296
372
  size_t index = 0;
297
- while (index < utf8.size()) {
298
- uint32_t code_point = decodeUtf8(utf8, index);
373
+ while (index < segment.size()) {
374
+ uint32_t code_point = decodeUtf8(segment, index);
299
375
  FT_UInt glyph = FT_Get_Char_Index(font->ft_face, code_point);
300
376
  float advance = 0;
301
377
  if (FT_Load_Glyph(font->ft_face, glyph, FT_LOAD_DEFAULT) == 0) {
@@ -304,6 +380,7 @@ float xt_text_shape_run(const std::string &utf8, Font *font, std::vector<ShapedG
304
380
  ShapedGlyph shaped;
305
381
  shaped.glyph = glyph;
306
382
  shaped.x_advance = advance;
383
+ shaped.font = font;
307
384
  total += advance;
308
385
  out.push_back(shaped);
309
386
  }
@@ -311,7 +388,7 @@ float xt_text_shape_run(const std::string &utf8, Font *font, std::vector<ShapedG
311
388
  }
312
389
 
313
390
  hb_buffer_t *buffer = hb_buffer_create();
314
- hb_buffer_add_utf8(buffer, utf8.c_str(), (int)utf8.size(), 0, -1);
391
+ hb_buffer_add_utf8(buffer, segment.c_str(), (int)segment.size(), 0, -1);
315
392
  hb_buffer_guess_segment_properties(buffer);
316
393
  hb_shape(font->hb_font, buffer, nullptr, 0);
317
394
  unsigned count = hb_buffer_get_length(buffer);
@@ -323,6 +400,7 @@ float xt_text_shape_run(const std::string &utf8, Font *font, std::vector<ShapedG
323
400
  shaped.x_advance = positions[i].x_advance / 64.0f;
324
401
  shaped.x_offset = positions[i].x_offset / 64.0f;
325
402
  shaped.y_offset = positions[i].y_offset / 64.0f;
403
+ shaped.font = font;
326
404
  total += shaped.x_advance;
327
405
  out.push_back(shaped);
328
406
  }
@@ -330,6 +408,56 @@ float xt_text_shape_run(const std::string &utf8, Font *font, std::vector<ShapedG
330
408
  return total;
331
409
  }
332
410
 
411
+ } // namespace
412
+
413
+ Font *xt_text_resolve(const FontSpec &spec) {
414
+ ensureInitialized();
415
+ Engine &e = engine();
416
+ if (!e.ft_ok) return nullptr;
417
+
418
+ bool mono = isMonospaceFamily(spec.family);
419
+ const std::string &path = mono ? e.mono_path : e.sans_path;
420
+ if (path.empty()) return nullptr;
421
+ return resolveFacePath(path, spec.pixel_size, mono);
422
+ }
423
+
424
+ Font *xt_text_scale(Font *font, float pixel_size) {
425
+ if (font == nullptr) return nullptr;
426
+ return resolveFacePath(font->path, pixel_size, font->monospace);
427
+ }
428
+
429
+ float xt_text_measure_width(const std::string &utf8, const FontSpec &spec) {
430
+ Font *font = xt_text_resolve(spec);
431
+ if (font == nullptr) return approximateWidth(utf8, spec.pixel_size, spec.family);
432
+ std::vector<ShapedGlyph> glyphs;
433
+ return xt_text_shape_run(utf8, font, glyphs);
434
+ }
435
+
436
+ float xt_text_shape_run(const std::string &utf8, Font *font, std::vector<ShapedGlyph> &out) {
437
+ if (font == nullptr) return 0;
438
+ float total = 0;
439
+ size_t index = 0;
440
+ size_t segment_start = 0;
441
+ Font *segment_font = nullptr;
442
+ while (index < utf8.size()) {
443
+ size_t char_start = index;
444
+ uint32_t code_point = decodeUtf8(utf8, index);
445
+ Font *chosen = faceForCodePoint(font, code_point);
446
+ if (segment_font == nullptr) {
447
+ segment_font = chosen;
448
+ segment_start = char_start;
449
+ } else if (chosen != segment_font) {
450
+ total += shapeSegment(utf8.substr(segment_start, char_start - segment_start), segment_font, out);
451
+ segment_font = chosen;
452
+ segment_start = char_start;
453
+ }
454
+ }
455
+ if (segment_font != nullptr) {
456
+ total += shapeSegment(utf8.substr(segment_start), segment_font, out);
457
+ }
458
+ return total;
459
+ }
460
+
333
461
  bool xt_text_rasterize(Font *font, uint32_t glyph, GlyphImage &out) {
334
462
  out.width = 0;
335
463
  out.height = 0;
@@ -374,6 +502,7 @@ bool xt_text_ready() {
374
502
 
375
503
  void xt_text_shutdown() {
376
504
  Engine &e = engine();
505
+ e.fallbacks.clear();
377
506
  e.cache.clear();
378
507
  for (auto &entry : e.hb_faces) {
379
508
  if (entry.second != nullptr) hb_face_destroy(entry.second);
@@ -387,6 +516,8 @@ void xt_text_shutdown() {
387
516
  e.initialized = false;
388
517
  e.sans_path.clear();
389
518
  e.mono_path.clear();
519
+ e.sans_fallbacks.clear();
520
+ e.mono_fallbacks.clear();
390
521
  }
391
522
 
392
523
  } // namespace xtgui
@@ -29,12 +29,15 @@ struct FontSpec {
29
29
  /** Opaque loaded face (defined in text.cpp). */
30
30
  struct Font;
31
31
 
32
- /** A positioned, shaped glyph (pixels). */
32
+ /** A positioned, shaped glyph (pixels). `font` is the face the glyph id
33
+ * belongs to — the primary face or, for a codepoint it does not cover, a
34
+ * fallback face — so the rasteriser can load the right face. */
33
35
  struct ShapedGlyph {
34
36
  uint32_t glyph = 0;
35
37
  float x_advance = 0;
36
38
  float x_offset = 0;
37
39
  float y_offset = 0;
40
+ Font *font = nullptr;
38
41
  };
39
42
 
40
43
  /** An 8-bit grayscale glyph bitmap, as produced by FreeType. */
@@ -51,9 +54,15 @@ struct GlyphImage {
51
54
  Font *xt_text_resolve(const FontSpec &spec);
52
55
 
53
56
  /** Shape `utf8` with `font`, appending glyphs to `out`; returns the total
54
- * advance width in pixels. `out` is not cleared. */
57
+ * advance width in pixels. `out` is not cleared. Codepoints `font` does not
58
+ * cover are shaped with the first fallback face that has them, and each
59
+ * glyph's `font` says which face it belongs to. */
55
60
  float xt_text_shape_run(const std::string &utf8, Font *font, std::vector<ShapedGlyph> &out);
56
61
 
62
+ /** The same face as `font` at a different pixel size (used to rasterise glyphs
63
+ * at the display's scale factor), or null when it cannot be loaded. */
64
+ Font *xt_text_scale(Font *font, float pixel_size);
65
+
57
66
  /** Rasterise `glyph` from `font` into a grayscale bitmap. Returns false when
58
67
  * the glyph has no outline (e.g. a space) or cannot be rendered. */
59
68
  bool xt_text_rasterize(Font *font, uint32_t glyph, GlyphImage &out);