lib/filigree/src/fallback/core.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3 const font = @import("../font/root.zig");
4 const shape = @import("../shape/root.zig");
5 const unicode_data = @import("unicode");
6 const fallback_model = @import("model.zig");
7 const workspace_mod = @import("workspace.zig");
8
9 const Allocator = std.mem.Allocator;
10
11 pub const Candidate = struct {
12 font: *const shape.Font,
13 variations: []const shape.VariationSetting = &.{},
14 };
15
16 pub const Input = struct {
17 base: shape.Input,
18 candidates: []const Candidate = &.{},
19 };
20
21 pub const Segment = struct {
22 source: shape.SourceRange,
23 glyphs: shape.GlyphSpan,
24 candidate_index: usize,
25 missing_everywhere: bool = false,
26 };
27
28 const SelectedCandidate = struct {
29 font: *const shape.Font,
30 variations: []const shape.VariationSetting,
31 };
32
33 const CandidateSelection = struct {
34 candidate_index: usize,
35 missing_everywhere: bool,
36 };
37
38 const Scalar = fallback_model.Scalar;
39 const SourceCluster = fallback_model.SourceCluster;
40 const SegmentRange = fallback_model.SegmentRange;
41
42 const ContextOptions = struct {
43 script_runs: unicode_data.ScriptRunLimits,
44 output: shape.Output.Limits,
45 };
46
47 pub const Context = struct {
48 allocator: Allocator,
49 shaper: shape.Context,
50 script_context: unicode_data.ScriptRunContext,
51 segment_output: shape.Output,
52 workspace: workspace_mod.Workspace,
53
54 pub const Options: type = ContextOptions;
55
56 pub fn init(allocator: Allocator, options: Options) !Context {
57 var workspace = try workspace_mod.Workspace.init(allocator, .{
58 .max_source_units = options.script_runs.max_source_units,
59 });
60 errdefer workspace.deinit(allocator);
61 workspace.activate();
62 var script_context = try unicode_data.ScriptRunContext.init(allocator, options.script_runs);
63 errdefer script_context.deinit(allocator);
64 script_context.activate();
65 return .{
66 .allocator = allocator,
67 .shaper = shape.Context.init(allocator, .{}),
68 .script_context = script_context,
69 .segment_output = try shape.Output.init(allocator, options.output),
70 .workspace = workspace,
71 };
72 }
73
74 pub fn deinit(self: *Context) void {
75 self.workspace.deinit(self.allocator);
76 self.segment_output.deinit(self.allocator);
77 self.script_context.deinit(self.allocator);
78 self.shaper.deinit();
79 self.* = undefined;
80 }
81
82 pub fn reset(self: *Context) void {
83 self.workspace.reset();
84 self.segment_output.clearRetainingCapacity();
85 self.script_context.reset();
86 self.shaper.reset();
87 }
88
89 pub fn scriptRunStatus(self: *const Context) unicode_data.ScriptRunStatus {
90 return self.script_context.status();
91 }
92
93 pub fn workspaceStatus(self: *const Context) workspace_mod.Status {
94 return self.workspace.status();
95 }
96
97 pub fn shapeRun(self: *Context, input: Input, output: *shape.Output) !void {
98 return self.shapeRunWithSegments(input, output, self.allocator, null);
99 }
100
101 /// Reported segments grow through segment_allocator and must be released through it.
102 pub fn shapeRunSegmented(
103 self: *Context,
104 input: Input,
105 output: *shape.Output,
106 segment_allocator: Allocator,
107 segments: *std.ArrayListUnmanaged(Segment),
108 ) !void {
109 return self.shapeRunWithSegments(input, output, segment_allocator, segments);
110 }
111
112 fn shapeRunWithSegments(
113 self: *Context,
114 input: Input,
115 output: *shape.Output,
116 segment_allocator: Allocator,
117 segments: ?*std.ArrayListUnmanaged(Segment),
118 ) !void {
119 const automatic_script = usesAutomaticScript(input.base);
120 if (input.candidates.len == 0 and !automatic_script) {
121 if (segments) |items| items.clearRetainingCapacity();
122 try self.shaper.shapeRun(input.base, output);
123 if (segments != null) {
124 try appendOutputSegment(segment_allocator, segments, try sourceRange(input.base), 0, false, 0, output.glyphs.items.len);
125 }
126 return;
127 }
128
129 const source_byte_len = try input.base.text.byteLen();
130 if (source_byte_len > std.math.maxInt(u32)) return error.SourceTooLong;
131 const remaining_source_bytes: usize = std.math.maxInt(u32) - input.base.source_offset;
132 if (source_byte_len > remaining_source_bytes) return error.SourceTooLong;
133 try self.workspace.begin(input.base.text.scalarCapacityHint());
134
135 output.clearRetainingCapacity();
136 self.segment_output.clearRetainingCapacity();
137 self.script_context.reset();
138 self.shaper.reset();
139 if (segments) |items| items.clearRetainingCapacity();
140 errdefer output.clearRetainingCapacity();
141 errdefer self.reset();
142 errdefer if (segments) |items| items.clearRetainingCapacity();
143
144 output.direction = input.base.direction;
145 output.writing_mode = input.base.writing_mode;
146 output.output_order = input.base.output_order;
147
148 if (source_byte_len == 0) return;
149
150 try self.collectClusters(input.base.text);
151 if (automatic_script) try self.assignScripts(input.base.text);
152 const uses_fallback = self.assignCandidates(input);
153 if (!uses_fallback and !automatic_script) {
154 try self.shaper.shapeRun(input.base, output);
155 try appendOutputSegment(
156 segment_allocator,
157 segments,
158 .{
159 .start = input.base.source_offset,
160 .end = @intCast(input.base.source_offset + source_byte_len),
161 },
162 0,
163 false,
164 0,
165 output.glyphs.items.len,
166 );
167 return;
168 }
169
170 try self.buildRanges(automatic_script);
171 if (input.base.direction == .rtl and input.base.output_order == .visual) {
172 var range_index = self.workspace.ranges.items.len;
173 while (range_index > 0) {
174 range_index -= 1;
175 try self.shapeSegment(input, self.workspace.ranges.items[range_index], output, segment_allocator, segments);
176 }
177 } else {
178 for (self.workspace.ranges.items) |range| {
179 try self.shapeSegment(input, range, output, segment_allocator, segments);
180 }
181 }
182 }
183
184 fn assignScripts(self: *Context, source: shape.Source) !void {
185 const runs = try self.script_context.itemize(.{ .text = source });
186 var run_index: usize = 0;
187 for (self.workspace.clusters.items) |*cluster| {
188 while (run_index + 1 < runs.len and cluster.source.start >= runs[run_index].source.end) run_index += 1;
189 cluster.script = if (run_index < runs.len and cluster.source.start >= runs[run_index].source.start and cluster.source.end <= runs[run_index].source.end)
190 runs[run_index].script
191 else
192 .unknown;
193 }
194 }
195
196 fn collectClusters(self: *Context, source: shape.Source) !void {
197 self.workspace.scalars.clearRetainingCapacity();
198 self.workspace.clusters.clearRetainingCapacity();
199 var grapheme_state: unicode_data.GraphemeState = .{};
200 var iterator = try unicode_data.SourceIterator.init(source, 0);
201 while (try iterator.next()) |scalar| {
202 try self.appendScalar(.{
203 .codepoint = scalar.codepoint,
204 .start = scalar.source.start,
205 .end = scalar.source.end,
206 }, &grapheme_state);
207 }
208 self.mergeJoiningClusters();
209 }
210
211 fn appendScalar(self: *Context, scalar: Scalar, grapheme_state: *unicode_data.GraphemeState) !void {
212 const scalar_index: u32 = @intCast(self.workspace.scalars.items.len);
213 std.debug.assert(self.workspace.scalars.items.len < self.workspace.scalars.capacity);
214 self.workspace.scalars.appendAssumeCapacity(scalar);
215 const scalar_end = scalar_index + 1;
216 const merge = grapheme_state.consume(scalar.codepoint);
217 if (self.workspace.clusters.items.len == 0 or !merge) {
218 std.debug.assert(self.workspace.clusters.items.len < self.workspace.clusters.capacity);
219 self.workspace.clusters.appendAssumeCapacity(.{
220 .source = .{ .start = scalar.start, .end = scalar.end },
221 .scalars = .{ .start = scalar_index, .end = scalar_end },
222 });
223 return;
224 }
225 const last = &self.workspace.clusters.items[self.workspace.clusters.items.len - 1];
226 last.source.end = scalar.end;
227 last.scalars.end = scalar_end;
228 }
229
230 fn mergeJoiningClusters(self: *Context) void {
231 if (self.workspace.clusters.items.len < 2) return;
232 var write_index: usize = 0;
233 var read_index: usize = 1;
234 while (read_index < self.workspace.clusters.items.len) : (read_index += 1) {
235 if (self.clustersJoin(self.workspace.clusters.items[write_index], self.workspace.clusters.items[read_index])) {
236 self.workspace.clusters.items[write_index].source.end = self.workspace.clusters.items[read_index].source.end;
237 self.workspace.clusters.items[write_index].scalars.end = self.workspace.clusters.items[read_index].scalars.end;
238 } else {
239 write_index += 1;
240 if (write_index != read_index) self.workspace.clusters.items[write_index] = self.workspace.clusters.items[read_index];
241 }
242 }
243 self.workspace.clusters.shrinkRetainingCapacity(write_index + 1);
244 }
245
246 fn clustersJoin(self: *const Context, left: SourceCluster, right: SourceCluster) bool {
247 const previous = self.lastJoiningType(left) orelse return false;
248 const next = self.firstJoiningType(right) orelse return false;
249 return shape.joiningTypesConnect(previous, next);
250 }
251
252 fn firstJoiningType(self: *const Context, cluster: SourceCluster) ?shape.JoiningType {
253 var scalar_index: usize = @intCast(cluster.scalars.start);
254 const scalar_end: usize = @intCast(cluster.scalars.end);
255 while (scalar_index < scalar_end) : (scalar_index += 1) {
256 const found = shape.joiningType(self.workspace.scalars.items[scalar_index].codepoint);
257 if (found != .transparent) return found;
258 }
259 return null;
260 }
261
262 fn lastJoiningType(self: *const Context, cluster: SourceCluster) ?shape.JoiningType {
263 var scalar_index: usize = @intCast(cluster.scalars.end);
264 const scalar_start: usize = @intCast(cluster.scalars.start);
265 while (scalar_index > scalar_start) {
266 scalar_index -= 1;
267 const found = shape.joiningType(self.workspace.scalars.items[scalar_index].codepoint);
268 if (found != .transparent) return found;
269 }
270 return null;
271 }
272
273 fn assignCandidates(self: *Context, input: Input) bool {
274 var uses_fallback = false;
275 for (self.workspace.clusters.items) |*cluster| {
276 const selected = self.bestCandidate(input, cluster.*);
277 cluster.candidate_index = selected.candidate_index;
278 cluster.missing_everywhere = selected.missing_everywhere;
279 uses_fallback = uses_fallback or cluster.candidate_index != 0 or cluster.missing_everywhere;
280 }
281 return uses_fallback;
282 }
283
284 fn bestCandidate(self: *const Context, input: Input, cluster: SourceCluster) CandidateSelection {
285 if (self.clusterCoveredBy(candidateAt(input, 0), cluster)) return .{ .candidate_index = 0, .missing_everywhere = false };
286 for (input.candidates, 0..) |_, candidate_index| {
287 const selected_index = candidate_index + 1;
288 if (self.clusterCoveredBy(candidateAt(input, selected_index), cluster)) {
289 return .{ .candidate_index = selected_index, .missing_everywhere = false };
290 }
291 }
292 return .{ .candidate_index = 0, .missing_everywhere = true };
293 }
294
295 fn clusterCoveredBy(self: *const Context, candidate: SelectedCandidate, cluster: SourceCluster) bool {
296 var scalar_index: usize = @intCast(cluster.scalars.start);
297 const scalar_end: usize = @intCast(cluster.scalars.end);
298 while (scalar_index < scalar_end) {
299 const scalar = self.workspace.scalars.items[scalar_index];
300 if (scalar_index + 1 < scalar_end) {
301 const next = self.workspace.scalars.items[scalar_index + 1];
302 if (isVariationSelector(next.codepoint)) {
303 if (candidate.font.face.glyphIdForVariation(scalar.codepoint, next.codepoint)) |glyph_id| {
304 if (glyph_id == 0) return false;
305 scalar_index += 2;
306 continue;
307 }
308 }
309 }
310 if (!coverageIgnoresScalar(scalar.codepoint) and candidate.font.face.glyphId(scalar.codepoint) == 0) return false;
311 scalar_index += 1;
312 }
313 return true;
314 }
315
316 fn buildRanges(self: *Context, split_by_script: bool) !void {
317 self.workspace.ranges.clearRetainingCapacity();
318 var cluster_start: usize = 0;
319 while (cluster_start < self.workspace.clusters.items.len) {
320 const candidate_index = self.workspace.clusters.items[cluster_start].candidate_index;
321 const missing_everywhere = self.workspace.clusters.items[cluster_start].missing_everywhere;
322 const range_script = self.workspace.clusters.items[cluster_start].script;
323 var cluster_end = cluster_start + 1;
324 while (cluster_end < self.workspace.clusters.items.len and
325 self.workspace.clusters.items[cluster_end].candidate_index == candidate_index and
326 self.workspace.clusters.items[cluster_end].missing_everywhere == missing_everywhere and
327 (!split_by_script or self.workspace.clusters.items[cluster_end].script == range_script))
328 {
329 cluster_end += 1;
330 }
331 std.debug.assert(self.workspace.ranges.items.len < self.workspace.ranges.capacity);
332 self.workspace.ranges.appendAssumeCapacity(.{
333 .cluster_start = @intCast(cluster_start),
334 .cluster_end = @intCast(cluster_end),
335 .candidate_index = candidate_index,
336 .missing_everywhere = missing_everywhere,
337 .script = range_script,
338 });
339 cluster_start = cluster_end;
340 }
341 }
342
343 fn shapeSegment(
344 self: *Context,
345 input: Input,
346 range: SegmentRange,
347 output: *shape.Output,
348 segment_allocator: Allocator,
349 segments: ?*std.ArrayListUnmanaged(Segment),
350 ) !void {
351 const cluster_start: usize = @intCast(range.cluster_start);
352 const cluster_end: usize = @intCast(range.cluster_end);
353 const source_start = self.workspace.clusters.items[cluster_start].source.start;
354 const source_end = self.workspace.clusters.items[cluster_end - 1].source.end;
355 const selected = candidateAt(input, range.candidate_index);
356 const glyph_start = output.glyphs.items.len;
357 var segment_input = input.base;
358 segment_input.font = selected.font;
359 segment_input.variations = selected.variations;
360 segment_input.text = sliceSource(input.base.text, source_start, source_end);
361 segment_input.source_offset = try addSourceOffset(input.base.source_offset, source_start);
362 segment_input.pre_context = null;
363 segment_input.post_context = null;
364
365 if (usesAutomaticScript(input.base)) {
366 const script_tags = unicode_data.scriptOpenTypeTags(range.script);
367 segment_input.script = script_tags.tags[0];
368 segment_input.script_tags = script_tags.slice();
369 try self.shaper.shapeRun(segment_input, &self.segment_output);
370 } else {
371 try self.shaper.shapeRun(segment_input, &self.segment_output);
372 }
373 try appendSegmentOutput(output, &self.segment_output);
374 try appendOutputSegment(
375 segment_allocator,
376 segments,
377 .{
378 .start = try addSourceOffset(input.base.source_offset, source_start),
379 .end = try addSourceOffset(input.base.source_offset, source_end),
380 },
381 range.candidate_index,
382 range.missing_everywhere,
383 glyph_start,
384 output.glyphs.items.len,
385 );
386 }
387 };
388
389 fn usesAutomaticScript(input: shape.Input) bool {
390 return input.script == font.defaultScriptTag and input.script_tags.len == 0;
391 }
392
393 fn candidateAt(input: Input, index: usize) SelectedCandidate {
394 if (index == 0) {
395 return .{
396 .font = input.base.font,
397 .variations = input.base.variations,
398 };
399 }
400 const candidate = input.candidates[index - 1];
401 return .{
402 .font = candidate.font,
403 .variations = candidate.variations,
404 };
405 }
406
407 fn appendSegmentOutput(output: *shape.Output, segment_output: *const shape.Output) !void {
408 const glyph_offset = output.glyphs.items.len;
409 const cluster_offset = output.clusters.items.len;
410 const caret_offset = output.ligature_carets.items.len;
411
412 if (glyph_offset > std.math.maxInt(u32) or cluster_offset > std.math.maxInt(u32) or caret_offset > std.math.maxInt(u32)) return error.OutputTooLarge;
413 try output.ensureUnused(
414 segment_output.glyphs.items.len,
415 segment_output.clusters.items.len,
416 segment_output.ligature_carets.items.len,
417 );
418 output.ligature_carets.appendSliceAssumeCapacity(segment_output.ligature_carets.items);
419
420 const glyph_offset_u32: u32 = @intCast(glyph_offset);
421 const cluster_offset_u32: u32 = @intCast(cluster_offset);
422 const caret_offset_u32: u32 = @intCast(caret_offset);
423
424 for (segment_output.glyphs.items) |glyph| {
425 var appended = glyph;
426 appended.cluster_index = try addU32(appended.cluster_index, cluster_offset_u32);
427 if (appended.ligature_caret_count != 0) appended.ligature_caret_start = try addU32(appended.ligature_caret_start, caret_offset_u32);
428 output.glyphs.appendAssumeCapacity(appended);
429 }
430
431 for (segment_output.clusters.items) |cluster| {
432 var appended = cluster;
433 appended.glyphs.start = try addU32(appended.glyphs.start, glyph_offset_u32);
434 appended.glyphs.end = try addU32(appended.glyphs.end, glyph_offset_u32);
435 output.clusters.appendAssumeCapacity(appended);
436 }
437
438 output.total_x_advance = addClampedI32(output.total_x_advance, segment_output.total_x_advance);
439 output.total_y_advance = addClampedI32(output.total_y_advance, segment_output.total_y_advance);
440 }
441
442 fn appendOutputSegment(
443 segment_allocator: Allocator,
444 segments: ?*std.ArrayListUnmanaged(Segment),
445 source: shape.SourceRange,
446 candidate_index: usize,
447 missing_everywhere: bool,
448 glyph_start: usize,
449 glyph_end: usize,
450 ) !void {
451 if (glyph_start == glyph_end) return;
452 if (segments) |items| {
453 if (glyph_start > std.math.maxInt(u32) or glyph_end > std.math.maxInt(u32)) return error.OutputTooLarge;
454 try items.append(segment_allocator, .{
455 .source = source,
456 .glyphs = .{
457 .start = @intCast(glyph_start),
458 .end = @intCast(glyph_end),
459 },
460 .candidate_index = candidate_index,
461 .missing_everywhere = missing_everywhere,
462 });
463 }
464 }
465
466 fn sourceRange(input: shape.Input) !shape.SourceRange {
467 const source_byte_len = try input.text.byteLen();
468 if (source_byte_len > std.math.maxInt(u32)) return error.SourceTooLong;
469 if (source_byte_len > std.math.maxInt(u32) - input.source_offset) return error.SourceTooLong;
470 return .{
471 .start = input.source_offset,
472 .end = @intCast(input.source_offset + source_byte_len),
473 };
474 }
475
476 fn addSourceOffset(source_offset: u32, relative_start: u32) !u32 {
477 if (relative_start > std.math.maxInt(u32) - source_offset) return error.SourceTooLong;
478 return source_offset + relative_start;
479 }
480
481 fn sliceSource(source: shape.Source, start: u32, end: u32) shape.Source {
482 const slice_start: usize = @intCast(start);
483 const slice_end: usize = @intCast(end);
484 return switch (source) {
485 .utf8 => |text| .{ .utf8 = text[slice_start..slice_end] },
486 .utf16 => |text| .{ .utf16 = text[slice_start / 2 .. slice_end / 2] },
487 .utf32 => |text| .{ .utf32 = text[slice_start / 4 .. slice_end / 4] },
488 };
489 }
490
491 fn addU32(a: u32, b: u32) !u32 {
492 if (b > std.math.maxInt(u32) - a) return error.OutputTooLarge;
493 return a + b;
494 }
495
496 fn addClampedI32(a: i32, b: i32) i32 {
497 const sum = @as(i64, a) + @as(i64, b);
498 if (sum > std.math.maxInt(i32)) return std.math.maxInt(i32);
499 if (sum < std.math.minInt(i32)) return std.math.minInt(i32);
500 return @intCast(sum);
501 }
502
503 fn coverageIgnoresScalar(codepoint: u21) bool {
504 return isVariationSelector(codepoint) or
505 codepoint == 0x034f or
506 codepoint == 0x061c or
507 codepoint == 0x180e or
508 (codepoint >= 0x200b and codepoint <= 0x200f) or
509 (codepoint >= 0x202a and codepoint <= 0x202e) or
510 (codepoint >= 0x2060 and codepoint <= 0x206f) or
511 codepoint == 0x3164 or
512 codepoint == 0xfeff or
513 codepoint == 0xffa0 or
514 (codepoint >= 0xfff0 and codepoint <= 0xfff8) or
515 (codepoint >= 0x1bca0 and codepoint <= 0x1bca3) or
516 (codepoint >= 0x1d173 and codepoint <= 0x1d17a) or
517 (codepoint >= 0xe0000 and codepoint <= 0xe0fff);
518 }
519
520 fn isVariationSelector(codepoint: u21) bool {
521 return (codepoint >= 0xfe00 and codepoint <= 0xfe0f) or
522 (codepoint >= 0xe0100 and codepoint <= 0xe01ef);
523 }
524
525 test "Filigree fallback exposes script source unit capacity" {
526 comptime {
527 alloc_phase.capacity.record(
528 alloc_phase.capacity.witness(@import("unicode").ScriptRunContext, "unicode_script_run_consumer"),
529 );
530 }
531
532 var context = try Context.init(std.testing.allocator, .{
533 .script_runs = .{ .max_source_units = 3 },
534 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
535 });
536 defer context.deinit();
537 const status = context.scriptRunStatus();
538 try std.testing.expectEqual(@as(usize, 3), status.max_source_units);
539 try std.testing.expectEqual(
540 (try unicode_data.ScriptRunCapacity.derive(.{ .max_source_units = 3 })).storage_bytes,
541 status.storage_bytes,
542 );
543 }
544
545 test "Context uses fallback font for missing utf8 clusters" {
546 const allocator = std.testing.allocator;
547 const primary_bytes = try asciiRangeFont(allocator, 'A', 'A', false);
548 defer allocator.free(primary_bytes);
549 const fallback_bytes = try fixture_font.create(allocator);
550 defer allocator.free(fallback_bytes);
551
552 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
553 defer primary_font.deinit();
554 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
555 defer fallback_font.deinit();
556
557 var context = try Context.init(allocator, .{
558 .script_runs = .{ .max_source_units = 2 },
559 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
560 });
561 defer context.deinit();
562 var output = try shape.Output.init(allocator, .{
563 .max_glyphs = 256,
564 .max_ligature_carets = 256,
565 });
566 defer output.deinit(allocator);
567
568 const candidates = [_]Candidate{.{ .font = &fallback_font }};
569 try context.shapeRun(.{
570 .base = .{ .font = &primary_font, .text = .{ .utf8 = "AB" } },
571 .candidates = &candidates,
572 }, &output);
573
574 const run = output.run();
575 try std.testing.expectEqual(@as(usize, 2), run.glyphs.len);
576 try std.testing.expectEqual(@as(u32, 'A' - 31), run.glyphs[0].glyph_id);
577 try std.testing.expectEqual(@as(u32, 'B' - 31), run.glyphs[1].glyph_id);
578 try std.testing.expect(!run.glyphs[1].flags.missing_glyph);
579 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 1 }, run.clusters[0].source);
580 try std.testing.expectEqual(shape.SourceRange{ .start = 1, .end = 2 }, run.clusters[1].source);
581 }
582
583 test "Context reports segmented fallback font selection" {
584 const allocator = std.testing.allocator;
585 const primary_bytes = try asciiRangeFont(allocator, 'A', 'A', false);
586 defer allocator.free(primary_bytes);
587 const fallback_bytes = try fixture_font.create(allocator);
588 defer allocator.free(fallback_bytes);
589
590 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
591 defer primary_font.deinit();
592 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
593 defer fallback_font.deinit();
594
595 var context = try Context.init(allocator, .{
596 .script_runs = .{ .max_source_units = 2 },
597 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
598 });
599 defer context.deinit();
600 var output = try shape.Output.init(allocator, .{
601 .max_glyphs = 256,
602 .max_ligature_carets = 256,
603 });
604 defer output.deinit(allocator);
605 var segments: std.ArrayListUnmanaged(Segment) = .empty;
606 defer segments.deinit(allocator);
607
608 const candidates = [_]Candidate{.{ .font = &fallback_font }};
609 try context.shapeRunSegmented(.{
610 .base = .{ .font = &primary_font, .text = .{ .utf8 = "AB" } },
611 .candidates = &candidates,
612 }, &output, allocator, &segments);
613
614 try std.testing.expectEqual(@as(usize, 2), segments.items.len);
615 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 1 }, segments.items[0].source);
616 try std.testing.expectEqual(shape.GlyphSpan{ .start = 0, .end = 1 }, segments.items[0].glyphs);
617 try std.testing.expectEqual(@as(usize, 0), segments.items[0].candidate_index);
618 try std.testing.expect(!segments.items[0].missing_everywhere);
619 try std.testing.expectEqual(shape.SourceRange{ .start = 1, .end = 2 }, segments.items[1].source);
620 try std.testing.expectEqual(shape.GlyphSpan{ .start = 1, .end = 2 }, segments.items[1].glyphs);
621 try std.testing.expectEqual(@as(usize, 1), segments.items[1].candidate_index);
622 try std.testing.expect(!segments.items[1].missing_everywhere);
623 }
624
625 test "Context reports a final notdef segment when every face is missing" {
626 const allocator = std.testing.allocator;
627 const primary_bytes = try asciiRangeFont(allocator, 'A', 'A', false);
628 defer allocator.free(primary_bytes);
629 const fallback_bytes = try fixture_font.create(allocator);
630 defer allocator.free(fallback_bytes);
631 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
632 defer primary_font.deinit();
633 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
634 defer fallback_font.deinit();
635 var context = try Context.init(allocator, .{
636 .script_runs = .{ .max_source_units = 2 },
637 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
638 });
639 defer context.deinit();
640 var output = try shape.Output.init(allocator, .{
641 .max_glyphs = 256,
642 .max_ligature_carets = 256,
643 });
644 defer output.deinit(allocator);
645 var segments: std.ArrayListUnmanaged(Segment) = .empty;
646 defer segments.deinit(allocator);
647 const candidates = [_]Candidate{.{ .font = &fallback_font }};
648
649 try context.shapeRunSegmented(.{
650 .base = .{ .font = &primary_font, .text = .{ .utf8 = "\u{3b2}" } },
651 .candidates = &candidates,
652 }, &output, allocator, &segments);
653
654 try std.testing.expectEqual(@as(usize, 1), segments.items.len);
655 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 2 }, segments.items[0].source);
656 try std.testing.expectEqual(@as(usize, 0), segments.items[0].candidate_index);
657 try std.testing.expect(segments.items[0].missing_everywhere);
658 try std.testing.expectEqual(@as(u32, 0), output.run().glyphs[0].glyph_id);
659 }
660
661 test "Context accepts exact fallback source capacity and rejects max plus one transactionally" {
662 const allocator = std.testing.allocator;
663 const primary_bytes = try asciiRangeFont(allocator, 'A', 'A', false);
664 defer allocator.free(primary_bytes);
665 const fallback_bytes = try fixture_font.create(allocator);
666 defer allocator.free(fallback_bytes);
667 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
668 defer primary_font.deinit();
669 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
670 defer fallback_font.deinit();
671 var context = try Context.init(allocator, .{
672 .script_runs = .{ .max_source_units = 2 },
673 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
674 });
675 defer context.deinit();
676 var output = try shape.Output.init(allocator, .{
677 .max_glyphs = 256,
678 .max_ligature_carets = 256,
679 });
680 defer output.deinit(allocator);
681 var segments: std.ArrayListUnmanaged(Segment) = .empty;
682 defer segments.deinit(allocator);
683 const candidates = [_]Candidate{.{ .font = &fallback_font }};
684 try context.shapeRunSegmented(.{
685 .base = .{ .font = &primary_font, .text = .{ .utf8 = "AB" } },
686 .candidates = &candidates,
687 }, &output, allocator, &segments);
688 const prior_glyphs = output.glyphs.items[0..2].*;
689 const prior_segments = segments.items[0..2].*;
690 const output_pointer = output.glyphs.items.ptr;
691 const segment_pointer = segments.items.ptr;
692 const segment_capacity = segments.capacity;
693 const workspace_status = context.workspaceStatus();
694
695 try std.testing.expectError(error.SourceUnitCapacityExceeded, context.shapeRunSegmented(.{
696 .base = .{ .font = &primary_font, .text = .{ .utf8 = "ABC" } },
697 .candidates = &candidates,
698 }, &output, allocator, &segments));
699
700 try std.testing.expectEqualSlices(shape.ShapedGlyph, &prior_glyphs, output.glyphs.items);
701 try std.testing.expectEqualSlices(Segment, &prior_segments, segments.items);
702 try std.testing.expectEqual(output_pointer, output.glyphs.items.ptr);
703 try std.testing.expectEqual(segment_pointer, segments.items.ptr);
704 try std.testing.expectEqual(segment_capacity, segments.capacity);
705 try std.testing.expectEqual(workspace_status, context.workspaceStatus());
706 }
707
708 test "Context uses fallback font for missing utf16 clusters" {
709 const allocator = std.testing.allocator;
710 const primary_bytes = try asciiRangeFont(allocator, 'A', 'A', false);
711 defer allocator.free(primary_bytes);
712 const fallback_bytes = try fixture_font.create(allocator);
713 defer allocator.free(fallback_bytes);
714
715 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
716 defer primary_font.deinit();
717 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
718 defer fallback_font.deinit();
719
720 var context = try Context.init(allocator, .{
721 .script_runs = .{ .max_source_units = 2 },
722 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
723 });
724 defer context.deinit();
725 var output = try shape.Output.init(allocator, .{
726 .max_glyphs = 256,
727 .max_ligature_carets = 256,
728 });
729 defer output.deinit(allocator);
730
731 const text = [_]u16{ 'A', 'B' };
732 const candidates = [_]Candidate{.{ .font = &fallback_font }};
733 try context.shapeRun(.{
734 .base = .{ .font = &primary_font, .text = .{ .utf16 = &text } },
735 .candidates = &candidates,
736 }, &output);
737
738 const run = output.run();
739 try std.testing.expectEqual(@as(usize, 2), run.glyphs.len);
740 try std.testing.expectEqual(@as(u32, 'A' - 31), run.glyphs[0].glyph_id);
741 try std.testing.expectEqual(@as(u32, 'B' - 31), run.glyphs[1].glyph_id);
742 try std.testing.expect(!run.glyphs[1].flags.missing_glyph);
743 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 2 }, run.clusters[0].source);
744 try std.testing.expectEqual(shape.SourceRange{ .start = 2, .end = 4 }, run.clusters[1].source);
745 }
746
747 test "Context uses fallback font for missing utf32 clusters" {
748 const allocator = std.testing.allocator;
749 const primary_bytes = try asciiRangeFont(allocator, 'A', 'A', false);
750 defer allocator.free(primary_bytes);
751 const fallback_bytes = try fixture_font.create(allocator);
752 defer allocator.free(fallback_bytes);
753
754 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
755 defer primary_font.deinit();
756 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
757 defer fallback_font.deinit();
758
759 var context = try Context.init(allocator, .{
760 .script_runs = .{ .max_source_units = 2 },
761 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
762 });
763 defer context.deinit();
764 var output = try shape.Output.init(allocator, .{
765 .max_glyphs = 256,
766 .max_ligature_carets = 256,
767 });
768 defer output.deinit(allocator);
769
770 const text = [_]u32{ 'A', 'B' };
771 const candidates = [_]Candidate{.{ .font = &fallback_font }};
772 try context.shapeRun(.{
773 .base = .{ .font = &primary_font, .text = .{ .utf32 = &text } },
774 .candidates = &candidates,
775 }, &output);
776
777 const run = output.run();
778 try std.testing.expectEqual(@as(usize, 2), run.glyphs.len);
779 try std.testing.expectEqual(@as(u32, 'A' - 31), run.glyphs[0].glyph_id);
780 try std.testing.expectEqual(@as(u32, 'B' - 31), run.glyphs[1].glyph_id);
781 try std.testing.expect(!run.glyphs[1].flags.missing_glyph);
782 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 4 }, run.clusters[0].source);
783 try std.testing.expectEqual(shape.SourceRange{ .start = 4, .end = 8 }, run.clusters[1].source);
784 }
785
786 test "Context keeps fallback selection on grapheme boundaries" {
787 const allocator = std.testing.allocator;
788 const primary_bytes = try asciiRangeFont(allocator, 'A', 'A', false);
789 defer allocator.free(primary_bytes);
790 const fallback_bytes = try fixture_font.create(allocator);
791 defer allocator.free(fallback_bytes);
792
793 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
794 defer primary_font.deinit();
795 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
796 defer fallback_font.deinit();
797
798 var context = try Context.init(allocator, .{
799 .script_runs = .{ .max_source_units = 4 },
800 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
801 });
802 defer context.deinit();
803 var output = try shape.Output.init(allocator, .{
804 .max_glyphs = 256,
805 .max_ligature_carets = 256,
806 });
807 defer output.deinit(allocator);
808
809 const candidates = [_]Candidate{.{ .font = &fallback_font }};
810 try context.shapeRun(.{
811 .base = .{
812 .font = &primary_font,
813 .text = .{ .utf8 = "A\u{301}B" },
814 .cluster_mode = .monotone_graphemes,
815 },
816 .candidates = &candidates,
817 }, &output);
818
819 const run = output.run();
820 try std.testing.expectEqual(@as(usize, 3), run.glyphs.len);
821 try std.testing.expectEqual(@as(usize, 2), run.clusters.len);
822 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 3 }, run.clusters[0].source);
823 try std.testing.expectEqual(shape.GlyphSpan{ .start = 0, .end = 2 }, run.clusters[0].glyphs);
824 try std.testing.expectEqual(@as(u32, 'B' - 31), run.glyphs[2].glyph_id);
825 try std.testing.expect(!run.glyphs[2].flags.missing_glyph);
826 }
827
828 test "Context keeps variation sequences on one fallback font" {
829 const allocator = std.testing.allocator;
830 const primary_bytes = try asciiRangeFont(allocator, 'C', 'C', false);
831 defer allocator.free(primary_bytes);
832 const fallback_bytes = try fixture_font.createWithVariationSequences(allocator);
833 defer allocator.free(fallback_bytes);
834
835 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
836 defer primary_font.deinit();
837 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
838 defer fallback_font.deinit();
839
840 var context = try Context.init(allocator, .{
841 .script_runs = .{ .max_source_units = 5 },
842 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
843 });
844 defer context.deinit();
845 var output = try shape.Output.init(allocator, .{
846 .max_glyphs = 256,
847 .max_ligature_carets = 256,
848 });
849 defer output.deinit(allocator);
850
851 const candidates = [_]Candidate{.{ .font = &fallback_font }};
852 try context.shapeRun(.{
853 .base = .{
854 .font = &primary_font,
855 .text = .{ .utf8 = "A\u{fe0f}C" },
856 .cluster_mode = .monotone_graphemes,
857 },
858 .candidates = &candidates,
859 }, &output);
860
861 const run = output.run();
862 try std.testing.expectEqual(@as(usize, 2), run.glyphs.len);
863 try std.testing.expectEqual(@as(u32, 96), run.glyphs[0].glyph_id);
864 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 4 }, run.clusters[0].source);
865 try std.testing.expectEqual(@as(u32, 'C' - 31), run.glyphs[1].glyph_id);
866 try std.testing.expectEqual(shape.SourceRange{ .start = 4, .end = 5 }, run.clusters[1].source);
867 }
868
869 test "Context keeps Arabic joining sequences on one fallback font" {
870 const allocator = std.testing.allocator;
871 const primary_bytes = try fixture_font.createWithGsubArabicJoiningForms(allocator);
872 defer allocator.free(primary_bytes);
873 try patchFormat12GlyphId(primary_bytes, 0x0628, 0);
874 const fallback_bytes = try fixture_font.createWithGsubArabicJoiningForms(allocator);
875 defer allocator.free(fallback_bytes);
876
877 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len).?;
878 defer primary_font.deinit();
879 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len).?;
880 defer fallback_font.deinit();
881
882 var context = try Context.init(allocator, .{
883 .script_runs = .{ .max_source_units = 4 },
884 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
885 });
886 defer context.deinit();
887 var output = try shape.Output.init(allocator, .{
888 .max_glyphs = 256,
889 .max_ligature_carets = 256,
890 });
891 defer output.deinit(allocator);
892
893 const candidates = [_]Candidate{.{ .font = &fallback_font }};
894 try context.shapeRun(.{
895 .base = .{
896 .font = &primary_font,
897 .text = .{ .utf8 = "\u{0628}\u{0627}" },
898 .direction = .rtl,
899 .output_order = .logical,
900 },
901 .candidates = &candidates,
902 }, &output);
903
904 const run = output.run();
905 try std.testing.expectEqual(@as(usize, 2), run.glyphs.len);
906 try std.testing.expectEqual(@as(u32, 'I' - 31), run.glyphs[0].glyph_id);
907 try std.testing.expectEqual(@as(u32, 'B' - 31), run.glyphs[1].glyph_id);
908 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 2 }, run.clusters[0].source);
909 try std.testing.expectEqual(shape.SourceRange{ .start = 2, .end = 4 }, run.clusters[1].source);
910 }
911
912 test "Context itemizes script before shaping without fallback candidates" {
913 const allocator = std.testing.allocator;
914 const bytes = try fixture_font.createWithGsubLigature(allocator);
915 defer allocator.free(bytes);
916 try fixture_binary.replaceLayoutScript(bytes, "GSUB", "latn");
917
918 var shaped_font = shape.Font.initFromBytes(bytes.ptr, bytes.len).?;
919 defer shaped_font.deinit();
920
921 var direct_context = shape.Context.init(allocator, .{});
922 defer direct_context.deinit();
923 var direct_output = try shape.Output.init(allocator, .{
924 .max_glyphs = 256,
925 .max_ligature_carets = 256,
926 });
927 defer direct_output.deinit(allocator);
928
929 try direct_context.shapeRun(.{ .font = &shaped_font, .text = .{ .utf8 = "fi" } }, &direct_output);
930 try std.testing.expectEqual(@as(usize, 2), direct_output.run().glyphs.len);
931
932 var context = try Context.init(allocator, .{
933 .script_runs = .{ .max_source_units = 2 },
934 .output = .{ .max_glyphs = 256, .max_ligature_carets = 256 },
935 });
936 defer context.deinit();
937 var output = try shape.Output.init(allocator, .{
938 .max_glyphs = 256,
939 .max_ligature_carets = 256,
940 });
941 defer output.deinit(allocator);
942
943 try context.shapeRun(.{
944 .base = .{ .font = &shaped_font, .text = .{ .utf8 = "fi" } },
945 }, &output);
946
947 const run = output.run();
948 try std.testing.expectEqual(@as(usize, 1), run.glyphs.len);
949 try std.testing.expectEqual(@as(u32, 96), run.glyphs[0].glyph_id);
950 try std.testing.expectEqual(shape.SourceRange{ .start = 0, .end = 2 }, run.clusters[0].source);
951 }
952
953 const fixture_font = @import("../fixture/root.zig");
954 const fixture_binary = fixture_font.binary;
955
956 fn asciiRangeFont(allocator: Allocator, start_code: u16, end_code: u16, variation_sequences: bool) ![]u8 {
957 const bytes = if (variation_sequences)
958 try fixture_font.createWithVariationSequences(allocator)
959 else
960 try fixture_font.create(allocator);
961 errdefer allocator.free(bytes);
962 try patchAsciiRange(bytes, start_code, end_code);
963 return bytes;
964 }
965
966 fn patchAsciiRange(bytes: []u8, start_code: u16, end_code: u16) !void {
967 const cmap_offset = try fixture_binary.tableOffset(bytes, "cmap");
968 if (cmap_offset > bytes.len or bytes.len - cmap_offset < 4) return error.InvalidFont;
969 const subtable_count = readU16(bytes, cmap_offset + 2);
970 if (@as(usize, subtable_count) > (bytes.len - cmap_offset - 4) / 8) return error.InvalidFont;
971
972 for (0..subtable_count) |subtable_index| {
973 const record = cmap_offset + 4 + subtable_index * 8;
974 const subtable_offset = readU32(bytes, record + 4);
975 const subtable_start = cmap_offset + @as(usize, @intCast(subtable_offset));
976 if (subtable_start > bytes.len or bytes.len - subtable_start < 2) return error.InvalidFont;
977 if (readU16(bytes, subtable_start) != 4) continue;
978 if (bytes.len - subtable_start < 40) return error.InvalidFont;
979 writeU16(bytes, subtable_start + 14, end_code);
980 writeU16(bytes, subtable_start + 22, start_code);
981 return;
982 }
983
984 return error.MissingCmapFormat4;
985 }
986
987 fn patchFormat12GlyphId(bytes: []u8, codepoint: u32, glyph_id: u32) !void {
988 const cmap_offset = try fixture_binary.tableOffset(bytes, "cmap");
989 if (cmap_offset > bytes.len or bytes.len - cmap_offset < 4) return error.InvalidFont;
990 const subtable_count = readU16(bytes, cmap_offset + 2);
991 if (@as(usize, subtable_count) > (bytes.len - cmap_offset - 4) / 8) return error.InvalidFont;
992
993 for (0..subtable_count) |subtable_index| {
994 const record = cmap_offset + 4 + subtable_index * 8;
995 const subtable_offset = readU32(bytes, record + 4);
996 const subtable_start = cmap_offset + @as(usize, @intCast(subtable_offset));
997 if (subtable_start > bytes.len or bytes.len - subtable_start < 16) return error.InvalidFont;
998 if (readU16(bytes, subtable_start) != 12) continue;
999 const group_count = readU32(bytes, subtable_start + 12);
1000 if (@as(usize, @intCast(group_count)) > (bytes.len - subtable_start - 16) / 12) return error.InvalidFont;
1001 for (0..group_count) |group_index| {
1002 const group = subtable_start + 16 + group_index * 12;
1003 const start = readU32(bytes, group);
1004 const end = readU32(bytes, group + 4);
1005 if (codepoint < start or codepoint > end) continue;
1006 if (start != end) return error.UnsupportedFixturePatch;
1007 writeU32(bytes, group + 8, glyph_id);
1008 return;
1009 }
1010 }
1011
1012 return error.MissingCmapFormat12;
1013 }
1014
1015 fn readU16(bytes: []const u8, offset: usize) u16 {
1016 return std.mem.readInt(u16, bytes[offset..][0..2], .big);
1017 }
1018
1019 fn readU32(bytes: []const u8, offset: usize) u32 {
1020 return std.mem.readInt(u32, bytes[offset..][0..4], .big);
1021 }
1022
1023 fn writeU16(bytes: []u8, offset: usize, value: u16) void {
1024 std.mem.writeInt(u16, bytes[offset..][0..2], value, .big);
1025 }
1026
1027 fn writeU32(bytes: []u8, offset: usize, value: u32) void {
1028 std.mem.writeInt(u32, bytes[offset..][0..4], value, .big);
1029 }