lib/filigree/src/render/text.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const canvas_data = @import("canvas.zig");
3 const fallback = @import("../fallback/root.zig");
4 const font = @import("../font/root.zig");
5 const shape = @import("../shape/root.zig");
6
7 const Canvas = canvas_data.Canvas;
8 const Color = canvas_data.Color;
9
10 pub const DrawOptions = struct {
11 color: Color = .{},
12 };
13
14 pub const Metrics = struct {
15 advance_x: i32,
16 advance_y: i32,
17 };
18
19 /// Reuses caller-provisioned output; replaces its prior run.
20 pub fn measureUtf8(
21 allocator: std.mem.Allocator,
22 output: *shape.Output,
23 shaped_font: *const shape.Font,
24 text: []const u8,
25 ) !Metrics {
26 var context = shape.Context.init(allocator, .{});
27 defer context.deinit();
28 try context.shapeRun(.{
29 .font = shaped_font,
30 .text = .{ .utf8 = text },
31 }, output);
32 return measureGlyphRun(output.run());
33 }
34
35 /// Reuses caller-provisioned output; replaces its prior run.
36 pub fn measureFallbackUtf8(
37 allocator: std.mem.Allocator,
38 output: *shape.Output,
39 shaped_font: *const shape.Font,
40 fallback_candidates: []const fallback.Candidate,
41 text: []const u8,
42 ) !Metrics {
43 var context = try fallback.Context.init(allocator, .{
44 .script_runs = .{ .max_source_units = text.len },
45 .output = output.capacity.limits,
46 });
47 defer context.deinit();
48 try context.shapeRun(.{
49 .base = .{
50 .font = shaped_font,
51 .text = .{ .utf8 = text },
52 },
53 .candidates = fallback_candidates,
54 }, output);
55 return measureGlyphRun(output.run());
56 }
57
58 pub fn measureGlyphRun(run: shape.GlyphRun) Metrics {
59 return .{
60 .advance_x = round26Dot6(run.total_x_advance),
61 .advance_y = round26Dot6(run.total_y_advance),
62 };
63 }
64
65 /// Reuses caller-provisioned output; replaces its prior run.
66 pub fn drawUtf8(
67 allocator: std.mem.Allocator,
68 output: *shape.Output,
69 canvas: Canvas,
70 shaped_font: *const shape.Font,
71 text: []const u8,
72 pixel_size: i32,
73 origin_x: i32,
74 origin_y: i32,
75 options: DrawOptions,
76 ) !Metrics {
77 var context = shape.Context.init(allocator, .{});
78 defer context.deinit();
79 try context.shapeRun(.{
80 .font = shaped_font,
81 .text = .{ .utf8 = text },
82 }, output);
83 return try drawGlyphRun(
84 allocator,
85 canvas,
86 shaped_font.face,
87 output.run(),
88 pixel_size,
89 origin_x,
90 origin_y,
91 options,
92 );
93 }
94
95 /// Reuses caller-provisioned output; replaces its prior run.
96 pub fn drawFallbackUtf8(
97 allocator: std.mem.Allocator,
98 output: *shape.Output,
99 canvas: Canvas,
100 shaped_font: *const shape.Font,
101 fallback_candidates: []const fallback.Candidate,
102 text: []const u8,
103 pixel_size: i32,
104 origin_x: i32,
105 origin_y: i32,
106 options: DrawOptions,
107 ) !Metrics {
108 var context = try fallback.Context.init(allocator, .{
109 .script_runs = .{ .max_source_units = text.len },
110 .output = output.capacity.limits,
111 });
112 defer context.deinit();
113 var segments: std.ArrayListUnmanaged(fallback.Segment) = .empty;
114 defer segments.deinit(allocator);
115 const input = fallback.Input{
116 .base = .{
117 .font = shaped_font,
118 .text = .{ .utf8 = text },
119 },
120 .candidates = fallback_candidates,
121 };
122 try context.shapeRunSegmented(input, output, allocator, &segments);
123 return try drawFallbackGlyphRun(
124 allocator,
125 canvas,
126 input,
127 output.run(),
128 segments.items,
129 pixel_size,
130 origin_x,
131 origin_y,
132 options,
133 );
134 }
135
136 pub fn drawGlyphRun(
137 allocator: std.mem.Allocator,
138 canvas: Canvas,
139 face: font.Face,
140 run: shape.GlyphRun,
141 pixel_size: i32,
142 origin_x: i32,
143 origin_y: i32,
144 options: DrawOptions,
145 ) !Metrics {
146 var pen_x = origin_x * 64;
147 var pen_y = origin_y * 64;
148 for (run.glyphs) |glyph| {
149 var bitmap = try font.glyphBitmapAlloc(allocator, allocator, face, glyph.glyph_id, pixel_size);
150 defer bitmap.deinit(allocator);
151 const dst_x = round26Dot6(pen_x + glyph.x_offset) + bitmap.offset_x;
152 const dst_y = round26Dot6(pen_y + glyph.y_offset) + bitmap.offset_y;
153 canvas_data.blitBitmap(canvas, bitmap, dst_x, dst_y, options.color);
154 pen_x += glyph.x_advance;
155 pen_y += glyph.y_advance;
156 }
157 return measureGlyphRun(run);
158 }
159
160 fn drawFallbackGlyphRun(
161 allocator: std.mem.Allocator,
162 canvas: Canvas,
163 input: fallback.Input,
164 run: shape.GlyphRun,
165 segments: []const fallback.Segment,
166 pixel_size: i32,
167 origin_x: i32,
168 origin_y: i32,
169 options: DrawOptions,
170 ) !Metrics {
171 var pen_x = origin_x * 64;
172 var pen_y = origin_y * 64;
173 var glyph_index: usize = 0;
174 for (segments) |segment| {
175 const start: usize = @intCast(segment.glyphs.start);
176 const end: usize = @intCast(segment.glyphs.end);
177 while (glyph_index < start and glyph_index < run.glyphs.len) : (glyph_index += 1) {
178 try drawGlyph(allocator, canvas, input.base.font.face, run.glyphs[glyph_index], pixel_size, &pen_x, &pen_y, options);
179 }
180 const face = fallbackFace(input, segment.candidate_index) orelse return error.InvalidFallbackCandidate;
181 while (glyph_index < end and glyph_index < run.glyphs.len) : (glyph_index += 1) {
182 try drawGlyph(allocator, canvas, face, run.glyphs[glyph_index], pixel_size, &pen_x, &pen_y, options);
183 }
184 }
185 while (glyph_index < run.glyphs.len) : (glyph_index += 1) {
186 try drawGlyph(allocator, canvas, input.base.font.face, run.glyphs[glyph_index], pixel_size, &pen_x, &pen_y, options);
187 }
188 return measureGlyphRun(run);
189 }
190
191 fn fallbackFace(input: fallback.Input, candidate_index: usize) ?font.Face {
192 if (candidate_index == 0) return input.base.font.face;
193 const index = candidate_index - 1;
194 if (index >= input.candidates.len) return null;
195 return input.candidates[index].font.face;
196 }
197
198 fn drawGlyph(
199 allocator: std.mem.Allocator,
200 canvas: Canvas,
201 face: font.Face,
202 glyph: shape.ShapedGlyph,
203 pixel_size: i32,
204 pen_x: *i32,
205 pen_y: *i32,
206 options: DrawOptions,
207 ) !void {
208 var bitmap = try font.glyphBitmapAlloc(allocator, allocator, face, glyph.glyph_id, pixel_size);
209 defer bitmap.deinit(allocator);
210 const dst_x = round26Dot6(pen_x.* + glyph.x_offset) + bitmap.offset_x;
211 const dst_y = round26Dot6(pen_y.* + glyph.y_offset) + bitmap.offset_y;
212 canvas_data.blitBitmap(canvas, bitmap, dst_x, dst_y, options.color);
213 pen_x.* += glyph.x_advance;
214 pen_y.* += glyph.y_advance;
215 }
216
217 fn round26Dot6(value: i32) i32 {
218 if (value >= 0) return @divTrunc(value + 32, 64);
219 return @divTrunc(value - 32, 64);
220 }
221
222 fn hasVisiblePixel(pixels: []const u8) bool {
223 var index: usize = 3;
224 while (index < pixels.len) : (index += 4) {
225 if (pixels[index] != 0) return true;
226 }
227 return false;
228 }
229
230 test "render draws shaped utf8 into rgba canvas" {
231 const fixtures = @import("../fixture/root.zig");
232 const allocator = std.testing.allocator;
233 const bytes = try fixtures.createWithOutlines(allocator);
234 defer allocator.free(bytes);
235
236 var shaped_font = shape.Font.initFromBytes(bytes.ptr, bytes.len) orelse return error.TestUnexpectedResult;
237 defer shaped_font.deinit();
238 shaped_font.setPixelHeightScale(20);
239
240 var pixels = @as([(64 * 32 * 4)]u8, @splat(0));
241 const canvas = try Canvas.init(&pixels, 64, 32, 64 * 4);
242 var output = try shape.Output.init(allocator, .{
243 .max_glyphs = 16,
244 .max_ligature_carets = 16,
245 });
246 defer output.deinit(allocator);
247 const metrics = try drawUtf8(allocator, &output, canvas, &shaped_font, "A", 20, 4, 4, .{});
248
249 try std.testing.expectEqual(@as(i32, 10), metrics.advance_x);
250 try std.testing.expect(hasVisiblePixel(&pixels));
251 }
252
253 test "render measures shaped utf8 without a canvas" {
254 const fixtures = @import("../fixture/root.zig");
255 const allocator = std.testing.allocator;
256 const bytes = try fixtures.createWithOutlines(allocator);
257 defer allocator.free(bytes);
258
259 var shaped_font = shape.Font.initFromBytes(bytes.ptr, bytes.len) orelse return error.TestUnexpectedResult;
260 defer shaped_font.deinit();
261 shaped_font.setPixelHeightScale(20);
262
263 var output = try shape.Output.init(allocator, .{
264 .max_glyphs = 16,
265 .max_ligature_carets = 16,
266 });
267 defer output.deinit(allocator);
268 const metrics = try measureUtf8(allocator, &output, &shaped_font, "AB");
269 try std.testing.expectEqual(@as(i32, 20), metrics.advance_x);
270 try std.testing.expectEqual(@as(i32, 0), metrics.advance_y);
271 }
272
273 test "render draws fallback-shaped utf8 into rgba canvas" {
274 const fixtures = @import("../fixture/root.zig");
275 const allocator = std.testing.allocator;
276 const primary_bytes = try testAsciiRangeFont(allocator, 'A', 'A');
277 defer allocator.free(primary_bytes);
278 const fallback_bytes = try fixtures.createWithOutlines(allocator);
279 defer allocator.free(fallback_bytes);
280
281 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len) orelse return error.TestUnexpectedResult;
282 defer primary_font.deinit();
283 primary_font.setPixelHeightScale(20);
284 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len) orelse return error.TestUnexpectedResult;
285 defer fallback_font.deinit();
286 fallback_font.setPixelHeightScale(20);
287
288 var pixels = @as([(64 * 32 * 4)]u8, @splat(0));
289 const canvas = try Canvas.init(&pixels, 64, 32, 64 * 4);
290 const candidates = [_]fallback.Candidate{.{ .font = &fallback_font }};
291 var output = try shape.Output.init(allocator, .{
292 .max_glyphs = 16,
293 .max_ligature_carets = 16,
294 });
295 defer output.deinit(allocator);
296 const metrics = try drawFallbackUtf8(
297 allocator,
298 &output,
299 canvas,
300 &primary_font,
301 &candidates,
302 "AB",
303 20,
304 4,
305 4,
306 .{},
307 );
308
309 try std.testing.expectEqual(@as(i32, 20), metrics.advance_x);
310 try std.testing.expect(hasVisiblePixel(&pixels));
311 }
312
313 test "render measures fallback-shaped utf8" {
314 const fixtures = @import("../fixture/root.zig");
315 const allocator = std.testing.allocator;
316 const primary_bytes = try testAsciiRangeFont(allocator, 'A', 'A');
317 defer allocator.free(primary_bytes);
318 const fallback_bytes = try fixtures.createWithOutlines(allocator);
319 defer allocator.free(fallback_bytes);
320
321 var primary_font = shape.Font.initFromBytes(primary_bytes.ptr, primary_bytes.len) orelse return error.TestUnexpectedResult;
322 defer primary_font.deinit();
323 primary_font.setPixelHeightScale(20);
324 var fallback_font = shape.Font.initFromBytes(fallback_bytes.ptr, fallback_bytes.len) orelse return error.TestUnexpectedResult;
325 defer fallback_font.deinit();
326 fallback_font.setPixelHeightScale(20);
327
328 const candidates = [_]fallback.Candidate{.{ .font = &fallback_font }};
329 var output = try shape.Output.init(allocator, .{
330 .max_glyphs = 16,
331 .max_ligature_carets = 16,
332 });
333 defer output.deinit(allocator);
334 const metrics = try measureFallbackUtf8(allocator, &output, &primary_font, &candidates, "AB");
335 try std.testing.expectEqual(@as(i32, 20), metrics.advance_x);
336 try std.testing.expectEqual(@as(i32, 0), metrics.advance_y);
337 }
338
339 fn testAsciiRangeFont(allocator: std.mem.Allocator, start_code: u16, end_code: u16) ![]u8 {
340 const fixtures = @import("../fixture/root.zig");
341 const bytes = try fixtures.createWithOutlines(allocator);
342 errdefer allocator.free(bytes);
343 try patchAsciiRange(bytes, start_code, end_code);
344 return bytes;
345 }
346
347 fn patchAsciiRange(bytes: []u8, start_code: u16, end_code: u16) !void {
348 const fixtures = @import("../fixture/root.zig");
349 const cmap_offset = try fixtures.binary.tableOffset(bytes, "cmap");
350 if (cmap_offset > bytes.len or bytes.len - cmap_offset < 4) return error.InvalidFont;
351 const subtable_count = readU16(bytes, cmap_offset + 2);
352 if (@as(usize, subtable_count) > (bytes.len - cmap_offset - 4) / 8) return error.InvalidFont;
353
354 for (0..subtable_count) |subtable_index| {
355 const record = cmap_offset + 4 + subtable_index * 8;
356 const subtable_offset = readU32(bytes, record + 4);
357 const subtable_start = cmap_offset + @as(usize, @intCast(subtable_offset));
358 if (subtable_start > bytes.len or bytes.len - subtable_start < 2) return error.InvalidFont;
359 if (readU16(bytes, subtable_start) != 4) continue;
360 if (bytes.len - subtable_start < 40) return error.InvalidFont;
361 writeU16(bytes, subtable_start + 14, end_code);
362 writeU16(bytes, subtable_start + 22, start_code);
363 return;
364 }
365
366 return error.MissingCmapFormat4;
367 }
368
369 fn readU16(bytes: []const u8, offset: usize) u16 {
370 return std.mem.readInt(u16, bytes[offset..][0..2], .big);
371 }
372
373 fn readU32(bytes: []const u8, offset: usize) u32 {
374 return std.mem.readInt(u32, bytes[offset..][0..4], .big);
375 }
376
377 fn writeU16(bytes: []u8, offset: usize, value: u16) void {
378 std.mem.writeInt(u16, bytes[offset..][0..2], value, .big);
379 }