lib/zen/src/diagram/ego/svg.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const root = @import("root.zig");
  4 const zen = @import("../../root.zig");
  5 
  6 const html = zen.html;
  7 const layout = root.layout;
  8 const model = root.model;
  9 
 10 pub const output_bytes_max: usize = 96 * 1024;
 11 pub const document_fragment_bytes_max: usize = output_bytes_max + 1024;
 12 const escaped_bytes_max: usize = model.href_bytes_max * 6;
 13 
 14 pub const FragmentError = error{
 15     InvalidDocumentFragment,
 16     UnsafeDocumentFragment,
 17 };
 18 
 19 const stylesheet =
 20     \\.zen-ego{color:var(--ink,#1a1714);overflow:visible}
 21     \\.zen-ego text{
 22     \\font-family:var(--face-mono,ui-monospace,monospace);
 23     \\pointer-events:none
 24     \\}
 25     \\.zen-ego-edge{
 26     \\fill:none;
 27     \\stroke:var(--rule-bold,rgba(26,23,20,.3));
 28     \\stroke-width:1.5;
 29     \\vector-effect:non-scaling-stroke
 30     \\}
 31     \\.zen-ego-edge-more{stroke-dasharray:4 4}
 32     \\.zen-ego-arrow{fill:var(--ink-mute,#8c8275)}
 33     \\.zen-ego-heading{
 34     \\fill:var(--ink-soft,#564e44);
 35     \\font-weight:700;
 36     \\letter-spacing:.08em;
 37     \\text-transform:uppercase
 38     \\}
 39     \\.zen-ego-empty{fill:var(--ink-mute,#8c8275);font-style:italic}
 40     \\.zen-ego-node>rect{
 41     \\fill:var(--paper,#f4f1ea);
 42     \\stroke:var(--rule-bold,rgba(26,23,20,.3));
 43     \\stroke-width:1.25;
 44     \\vector-effect:non-scaling-stroke
 45     \\}
 46     \\.zen-ego-node .zen-ego-label{fill:var(--ink,#1a1714);font-weight:700}
 47     \\.zen-ego-node .zen-ego-context{fill:var(--ink-soft,#564e44)}
 48     \\.zen-ego .zen-ego-node{text-decoration:none}
 49     \\.zen-ego-subject>rect{
 50     \\fill:var(--paper-sunk,#ece8dd);
 51     \\stroke:var(--accent-ink,#d2371f);
 52     \\stroke-width:2.25
 53     \\}
 54     \\.zen-ego-subject .zen-ego-label{font-weight:900}
 55     \\.zen-ego-more>rect{fill:transparent;stroke-dasharray:4 3}
 56     \\.zen-ego-badge rect{
 57     \\fill:var(--paper-raised,var(--paper,#f4f1ea));
 58     \\stroke:var(--rule-bold,rgba(26,23,20,.3));
 59     \\stroke-width:1
 60     \\}
 61     \\.zen-ego-badge text{
 62     \\fill:var(--ink-soft,#564e44);
 63     \\font-weight:800;
 64     \\letter-spacing:.04em
 65     \\}
 66     \\.zen-ego-self-loop{stroke:var(--accent-ink,#d2371f);stroke-width:2}
 67     \\a.zen-ego-node:is(:hover,:focus-visible)>rect{
 68     \\stroke:var(--accent-ink,#d2371f);
 69     \\stroke-width:2.5
 70     \\}
 71     \\a.zen-ego-node:is(:hover,:focus-visible) .zen-ego-label{
 72     \\fill:var(--accent-ink,#d2371f);
 73     \\text-decoration:underline
 74     \\}
 75     \\a.zen-ego-node:focus-visible{outline:none}
 76     \\a.zen-ego-node:focus-visible .zen-ego-badge rect{
 77     \\stroke:var(--accent-ink,#d2371f)
 78     \\}
 79     \\.zen-ego-node-plain>rect{stroke-dasharray:3 3}
 80 ;
 81 
 82 /// Renders one call map into caller-owned output storage.
 83 pub fn render(
 84     output: []u8,
 85     network: model.Network,
 86     surface: model.Surface,
 87 ) (model.Error || std.Io.Writer.Error)![]const u8 {
 88     var writer = std.Io.Writer.fixed(output);
 89     try write(&writer, network, surface);
 90     return writer.buffered();
 91 }
 92 
 93 /// Writes one bounded, linked, static SVG call map.
 94 pub fn write(
 95     writer: *std.Io.Writer,
 96     network: model.Network,
 97     surface: model.Surface,
 98 ) (model.Error || std.Io.Writer.Error)!void {
 99     const plan = try layout.Plan.inspect(network, surface);
100     const leaf = network.callers.total == 0 and network.callees.total == 0;
101     try writeOpen(writer, network, plan.geometry, leaf);
102     if (!(leaf and plan.geometry.arrangement == .horizontal)) {
103         try writeHeaders(writer, &plan);
104     }
105     try writeConnections(
106         writer,
107         &plan.callers,
108         plan.subject,
109         plan.geometry,
110         .callers,
111     );
112     try writeConnections(
113         writer,
114         &plan.callees,
115         plan.subject,
116         plan.geometry,
117         .callees,
118     );
119     try writeEmptyStates(writer, &plan);
120     try writeBand(writer, &plan.callers, plan.geometry, .callers);
121     try writeBand(writer, &plan.callees, plan.geometry, .callees);
122     if (network.recursive) try writeSelfLoop(writer, plan.subject);
123     try writeNode(writer, network.subject, plan.subject, plan.geometry, true);
124     try writer.writeAll("</svg>");
125 }
126 
127 /// Validates the documentation fragment envelope around one rendered call map.
128 pub fn validateDocumentFragment(source: []const u8) FragmentError!void {
129     const fragment = std.mem.trim(u8, source, " \t\r\n");
130     if (fragment.len == 0 or fragment.len > document_fragment_bytes_max) {
131         return error.InvalidDocumentFragment;
132     }
133     const wide =
134         "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide";
135     const anchored =
136         "<figure class=\"docs-call-map docs-call-map-anchored zen-role-wide";
137     if (!std.mem.startsWith(u8, fragment, wide) and
138         !std.mem.startsWith(u8, fragment, anchored))
139     {
140         return error.InvalidDocumentFragment;
141     }
142     if (!std.mem.endsWith(u8, fragment, "</figcaption></figure>")) {
143         return error.InvalidDocumentFragment;
144     }
145     if (!std.unicode.utf8ValidateSlice(fragment) or
146         containsAsciiInsensitive(fragment, "<script") or
147         containsAsciiInsensitive(fragment, "<foreignobject") or
148         containsEventAttribute(fragment) or
149         containsAsciiInsensitive(fragment, " style=") or
150         containsAsciiInsensitive(fragment, "xlink:href") or
151         containsAsciiInsensitive(fragment, "javascript:") or
152         containsAsciiInsensitive(fragment, "data:") or
153         containsAsciiInsensitive(fragment, "url(") or
154         containsAsciiInsensitive(fragment, "@import"))
155     {
156         return error.UnsafeDocumentFragment;
157     }
158     try validateFragmentTags(fragment);
159     try validateFragmentLinks(fragment);
160 }
161 
162 fn validateFragmentTags(fragment: []const u8) FragmentError!void {
163     const prefixes = [_][]const u8{
164         "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide",
165         "<figure class=\"docs-call-map docs-call-map-anchored zen-role-wide",
166         "</figure>",
167         "<svg ",
168         "</svg>",
169         "<style>",
170         "</style>",
171         "<path ",
172         "<text ",
173         "</text>",
174         "<a ",
175         "</a>",
176         "<rect ",
177         "<g ",
178         "</g>",
179         "<figcaption>",
180         "</figcaption>",
181     };
182     var cursor: usize = 0;
183     while (std.mem.indexOfScalarPos(u8, fragment, cursor, '<')) |start| {
184         const end = std.mem.indexOfScalarPos(u8, fragment, start, '>') orelse
185             return error.InvalidDocumentFragment;
186         var admitted = false;
187         for (prefixes) |prefix| {
188             admitted = admitted or std.mem.startsWith(u8, fragment[start..], prefix);
189         }
190         if (!admitted) return error.UnsafeDocumentFragment;
191         cursor = end + 1;
192     }
193     if (std.mem.count(u8, fragment, "<svg ") != 1 or
194         std.mem.count(u8, fragment, "</svg>") != 1)
195     {
196         return error.InvalidDocumentFragment;
197     }
198 }
199 
200 fn validateFragmentLinks(fragment: []const u8) FragmentError!void {
201     const marker = " href=\"";
202     var cursor: usize = 0;
203     while (std.mem.indexOfPos(u8, fragment, cursor, marker)) |start| {
204         const value_start = start + marker.len;
205         if (value_start == fragment.len) return error.InvalidDocumentFragment;
206         const end = std.mem.indexOfScalarPos(u8, fragment, value_start, '"') orelse
207             return error.InvalidDocumentFragment;
208         if (end == value_start) return error.InvalidDocumentFragment;
209         if (!model.safeHref(fragment[value_start..end])) {
210             return error.UnsafeDocumentFragment;
211         }
212         cursor = end + 1;
213     }
214 }
215 
216 fn containsAsciiInsensitive(haystack: []const u8, needle: []const u8) bool {
217     if (needle.len == 0 or needle.len > haystack.len) return false;
218     return std.ascii.findIgnoreCase(haystack, needle) != null;
219 }
220 
221 fn containsEventAttribute(fragment: []const u8) bool {
222     var tag_start: usize = 0;
223     while (std.mem.indexOfScalarPos(u8, fragment, tag_start, '<')) |start| {
224         const end = std.mem.indexOfScalarPos(u8, fragment, start, '>') orelse
225             return true;
226         var cursor = start + 1;
227         while (cursor + 3 < end) : (cursor += 1) {
228             if (!std.ascii.isWhitespace(fragment[cursor])) continue;
229             const name_start = cursor + 1;
230             if (std.ascii.toLower(fragment[name_start]) != 'o' or
231                 std.ascii.toLower(fragment[name_start + 1]) != 'n') continue;
232             var name_end = name_start + 2;
233             while (name_end < end and
234                 (std.ascii.isAlphanumeric(fragment[name_end]) or
235                     fragment[name_end] == '-' or fragment[name_end] == '_'))
236             {
237                 name_end += 1;
238             }
239             while (name_end < end and std.ascii.isWhitespace(fragment[name_end])) {
240                 name_end += 1;
241             }
242             if (name_end < end and fragment[name_end] == '=') return true;
243         }
244         tag_start = end + 1;
245     }
246     return false;
247 }
248 
249 const Direction = enum {
250     callers,
251     callees,
252 };
253 
254 fn writeOpen(
255     writer: *std.Io.Writer,
256     network: model.Network,
257     geometry: model.Geometry,
258     leaf: bool,
259 ) std.Io.Writer.Error!void {
260     try writer.print(
261         "<svg xmlns=\"http://www.w3.org/2000/svg\" " ++
262             "class=\"zen-ego{s}\" viewBox=\"0 0 {d} {d}\" " ++
263             "role=\"group\" aria-label=\"Static call map for ",
264         .{
265             if (leaf) " zen-ego-leaf" else "",
266             geometry.width,
267             geometry.height,
268         },
269     );
270     try writeAccessibleName(writer, network.subject);
271     try writer.writeAll(if (leaf and geometry.arrangement == .horizontal)
272         ". It has no direct callers and no direct calls."
273     else switch (geometry.arrangement) {
274         .horizontal => ". Callers flow in from the left. Calls flow out to the right.",
275         .vertical => ". Callers flow in from above. Calls flow out below.",
276     });
277     if (network.recursive) try writer.writeAll(" It calls itself.");
278     try writer.writeAll("\"><style>");
279     try writer.writeAll(stylesheet);
280     try writer.writeAll("</style>");
281 }
282 
283 fn writeHeaders(
284     writer: *std.Io.Writer,
285     plan: *const layout.Plan,
286 ) std.Io.Writer.Error!void {
287     try writeHeader(
288         writer,
289         plan.callers.heading.x,
290         plan.callers.heading.y,
291         plan.geometry.heading_size,
292         "Called by",
293     );
294     try writeHeader(
295         writer,
296         plan.callees.heading.x,
297         plan.callees.heading.y,
298         plan.geometry.heading_size,
299         "Calls",
300     );
301 }
302 
303 fn writeHeader(
304     writer: *std.Io.Writer,
305     x: f32,
306     y: f32,
307     size: f32,
308     value: []const u8,
309 ) std.Io.Writer.Error!void {
310     try writer.print(
311         "<text class=\"zen-ego-heading\" x=\"{d:.2}\" y=\"{d:.2}\" " ++
312             "font-size=\"{d:.2}\" text-anchor=\"middle\">{s}</text>",
313         .{ x, y, size, value },
314     );
315 }
316 
317 fn writeConnections(
318     writer: *std.Io.Writer,
319     band: *const layout.Band,
320     subject: layout.Rect,
321     geometry: model.Geometry,
322     direction: Direction,
323 ) std.Io.Writer.Error!void {
324     std.debug.assert(band.count <= model.max_slots_per_band);
325     for (band.slice()) |item| {
326         const dashed = switch (item.content) {
327             .node => false,
328             .more => true,
329         };
330         switch (geometry.arrangement) {
331             .horizontal => try writeHorizontalConnection(
332                 writer,
333                 item.box,
334                 subject,
335                 direction,
336                 dashed,
337             ),
338             .vertical => try writeVerticalConnection(
339                 writer,
340                 item.box,
341                 direction,
342                 dashed,
343             ),
344         }
345     }
346 }
347 
348 fn writeHorizontalConnection(
349     writer: *std.Io.Writer,
350     item: layout.Rect,
351     subject: layout.Rect,
352     direction: Direction,
353     dashed: bool,
354 ) std.Io.Writer.Error!void {
355     const start_x = if (direction == .callers)
356         item.x + item.width + 4
357     else
358         subject.x + subject.width + 4;
359     const end_x = if (direction == .callers) subject.x - 4 else item.x - 4;
360     const start_y = if (direction == .callers) item.centerY() else subject.centerY();
361     const end_y = if (direction == .callers) subject.centerY() else item.centerY();
362     try writeHorizontalEdge(writer, start_x, start_y, end_x, end_y, dashed);
363 }
364 
365 fn writeHorizontalEdge(
366     writer: *std.Io.Writer,
367     start_x: f32,
368     start_y: f32,
369     end_x: f32,
370     end_y: f32,
371     dashed: bool,
372 ) std.Io.Writer.Error!void {
373     const middle = (start_x + end_x) / 2;
374     try writer.print(
375         "<path class=\"zen-ego-edge{s}\" d=\"M {d:.2} {d:.2} " ++
376             "C {d:.2} {d:.2}, {d:.2} {d:.2}, {d:.2} {d:.2}\"/>",
377         .{
378             if (dashed) " zen-ego-edge-more" else "",
379             start_x,
380             start_y,
381             middle,
382             start_y,
383             middle,
384             end_y,
385             end_x,
386             end_y,
387         },
388     );
389     try writer.print(
390         "<path class=\"zen-ego-arrow\" d=\"M {d:.2} {d:.2} " ++
391             "L {d:.2} {d:.2} L {d:.2} {d:.2} Z\"/>",
392         .{ end_x, end_y, end_x - 7, end_y - 4, end_x - 7, end_y + 4 },
393     );
394 }
395 
396 fn writeVerticalConnection(
397     writer: *std.Io.Writer,
398     item: layout.Rect,
399     direction: Direction,
400     dashed: bool,
401 ) std.Io.Writer.Error!void {
402     const start_y = if (direction == .callers)
403         item.y + item.height + 1
404     else
405         item.y - 10;
406     const end_y = if (direction == .callers) start_y + 8 else item.y - 2;
407     try writeVerticalStub(writer, item.centerX(), start_y, end_y, dashed);
408 }
409 
410 fn writeVerticalStub(
411     writer: *std.Io.Writer,
412     x: f32,
413     start_y: f32,
414     end_y: f32,
415     dashed: bool,
416 ) std.Io.Writer.Error!void {
417     std.debug.assert(start_y < end_y);
418     try writer.print(
419         "<path class=\"zen-ego-edge{s}\" d=\"M {d:.2} {d:.2} L {d:.2} {d:.2}\"/>",
420         .{
421             if (dashed) " zen-ego-edge-more" else "",
422             x,
423             start_y,
424             x,
425             end_y,
426         },
427     );
428     try writer.print(
429         "<path class=\"zen-ego-arrow\" d=\"M {d:.2} {d:.2} " ++
430             "L {d:.2} {d:.2} L {d:.2} {d:.2} Z\"/>",
431         .{ x, end_y, x - 4, end_y - 7, x + 4, end_y - 7 },
432     );
433 }
434 
435 fn writeSelfLoop(
436     writer: *std.Io.Writer,
437     subject: layout.Rect,
438 ) std.Io.Writer.Error!void {
439     const start_x = subject.centerX() - 18;
440     const end_x = subject.centerX() + 18;
441     const end_y = subject.y - 3;
442     const top_y = end_y - 22;
443     try writer.print(
444         "<path class=\"zen-ego-edge zen-ego-self-loop\" aria-hidden=\"true\" " ++
445             "d=\"M {d:.2} {d:.2} C {d:.2} {d:.2}, {d:.2} {d:.2}, " ++
446             "{d:.2} {d:.2}\"/>",
447         .{ start_x, end_y, start_x, top_y, end_x, top_y, end_x, end_y },
448     );
449     try writer.print(
450         "<path class=\"zen-ego-arrow\" aria-hidden=\"true\" " ++
451             "d=\"M {d:.2} {d:.2} L {d:.2} {d:.2} L {d:.2} {d:.2} Z\"/>",
452         .{ end_x, end_y, end_x - 4, end_y - 7, end_x + 4, end_y - 7 },
453     );
454 }
455 
456 fn writeEmptyStates(
457     writer: *std.Io.Writer,
458     plan: *const layout.Plan,
459 ) std.Io.Writer.Error!void {
460     const geometry = plan.geometry;
461     if (plan.callers.count == 0) {
462         try writeEmpty(
463             writer,
464             plan.callers.empty.x,
465             plan.callers.empty.y,
466             geometry.empty_size,
467             "No direct callers",
468         );
469     }
470     if (plan.callees.count == 0) {
471         try writeEmpty(
472             writer,
473             plan.callees.empty.x,
474             plan.callees.empty.y,
475             geometry.empty_size,
476             "No direct calls",
477         );
478     }
479 }
480 
481 fn writeEmpty(
482     writer: *std.Io.Writer,
483     x: f32,
484     y: f32,
485     size: f32,
486     value: []const u8,
487 ) std.Io.Writer.Error!void {
488     try writer.print(
489         "<text class=\"zen-ego-empty\" x=\"{d:.2}\" y=\"{d:.2}\" " ++
490             "font-size=\"{d:.2}\" text-anchor=\"middle\" " ++
491             "dominant-baseline=\"middle\">{s}</text>",
492         .{ x, y, size, value },
493     );
494 }
495 
496 fn writeBand(
497     writer: *std.Io.Writer,
498     band: *const layout.Band,
499     geometry: model.Geometry,
500     direction: Direction,
501 ) std.Io.Writer.Error!void {
502     std.debug.assert(band.count <= model.max_slots_per_band);
503     for (band.slice()) |item| switch (item.content) {
504         .node => |node| try writeNode(writer, node, item.box, geometry, false),
505         .more => |more| try writeMore(writer, more, item.box, geometry, direction),
506     };
507 }
508 
509 fn writeNode(
510     writer: *std.Io.Writer,
511     node: model.Node,
512     box: layout.Rect,
513     geometry: model.Geometry,
514     subject: bool,
515 ) std.Io.Writer.Error!void {
516     const linked = node.href.len != 0;
517     try writer.writeAll(if (linked)
518         "<a class=\"zen-ego-node"
519     else
520         "<g class=\"zen-ego-node zen-ego-node-plain");
521     if (subject) try writer.writeAll(" zen-ego-subject");
522     if (linked) {
523         try writer.writeAll("\" href=\"");
524         try writeEscaped(writer, node.href);
525         try writer.writeAll("\" tabindex=\"0");
526     } else {
527         try writer.writeAll("\" role=\"img");
528     }
529     try writer.writeAll("\" aria-label=\"");
530     try writeAccessibleName(writer, node);
531     try writer.writeAll("\">");
532     try writeBox(writer, box);
533     const label_size = if (subject) geometry.subject_label_size else geometry.label_size;
534     if (node.badge.len > 0) try writeBadge(writer, node.badge, box, geometry.badge_size);
535     try writeNodeText(writer, node, box, label_size, geometry.context_size);
536     try writer.writeAll(if (linked) "</a>" else "</g>");
537 }
538 
539 fn writeMore(
540     writer: *std.Io.Writer,
541     more: layout.More,
542     box: layout.Rect,
543     geometry: model.Geometry,
544     direction: Direction,
545 ) std.Io.Writer.Error!void {
546     try writer.writeAll(
547         "<a class=\"zen-ego-node zen-ego-more\" href=\"",
548     );
549     try writeEscaped(writer, more.href);
550     try writer.print("\" tabindex=\"0\" aria-label=\"{d} more direct {s}\">", .{
551         more.count,
552         if (direction == .callers) "callers" else "calls",
553     });
554     try writeBox(writer, box);
555     try writer.print(
556         "<text class=\"zen-ego-label\" x=\"{d:.2}\" y=\"{d:.2}\" " ++
557             "font-size=\"{d:.2}\" text-anchor=\"middle\" " ++
558             "dominant-baseline=\"middle\">+{d} more</text></a>",
559         .{ box.centerX(), box.centerY(), geometry.label_size, more.count },
560     );
561 }
562 
563 fn writeBox(
564     writer: *std.Io.Writer,
565     box: layout.Rect,
566 ) std.Io.Writer.Error!void {
567     try writer.print(
568         "<rect x=\"{d:.2}\" y=\"{d:.2}\" width=\"{d:.2}\" " ++
569             "height=\"{d:.2}\" rx=\"8\"/>",
570         .{ box.x, box.y, box.width, box.height },
571     );
572 }
573 
574 fn writeNodeText(
575     writer: *std.Io.Writer,
576     node: model.Node,
577     box: layout.Rect,
578     label_size: f32,
579     context_size: f32,
580 ) std.Io.Writer.Error!void {
581     const center_x = box.centerX();
582     const center_y = box.centerY();
583     if (node.context.len == 0) {
584         try writeText(
585             writer,
586             "zen-ego-label",
587             node.label,
588             center_x,
589             center_y + if (node.badge.len > 0) @as(f32, 7) else 0,
590             label_size,
591             box.width - 14,
592         );
593         return;
594     }
595     const context_y = if (node.badge.len > 0) center_y + 2 else center_y - 9;
596     const label_y = if (node.badge.len > 0) center_y + 15 else center_y + 10;
597     try writeText(
598         writer,
599         "zen-ego-context",
600         node.context,
601         center_x,
602         context_y,
603         context_size,
604         box.width - 14,
605     );
606     try writeText(
607         writer,
608         "zen-ego-label",
609         node.label,
610         center_x,
611         label_y,
612         label_size,
613         box.width - 14,
614     );
615 }
616 
617 fn writeBadge(
618     writer: *std.Io.Writer,
619     value: []const u8,
620     box: layout.Rect,
621     size: f32,
622 ) std.Io.Writer.Error!void {
623     const glyphs = std.unicode.utf8CountCodepoints(value) catch unreachable;
624     const desired = @as(f32, @floatFromInt(glyphs)) * size * 0.62 + 12;
625     const width = @min(box.width - 12, @max(@as(f32, 34), desired));
626     const x = box.x + box.width - width - 6;
627     const y = box.y + 5;
628     try writer.print(
629         "<g class=\"zen-ego-badge\"><rect x=\"{d:.2}\" y=\"{d:.2}\" " ++
630             "width=\"{d:.2}\" height=\"14\" rx=\"7\"/>",
631         .{ x, y, width },
632     );
633     try writeText(writer, "zen-ego-badge-label", value, x + width / 2, y + 7, size, width - 8);
634     try writer.writeAll("</g>");
635 }
636 
637 fn writeText(
638     writer: *std.Io.Writer,
639     class: []const u8,
640     value: []const u8,
641     x: f32,
642     y: f32,
643     size: f32,
644     width: f32,
645 ) std.Io.Writer.Error!void {
646     try writer.print(
647         "<text class=\"{s}\" x=\"{d:.2}\" y=\"{d:.2}\" " ++
648             "font-size=\"{d:.2}\" text-anchor=\"middle\" " ++
649             "dominant-baseline=\"middle\"",
650         .{ class, x, y, size },
651     );
652     const glyphs = std.unicode.utf8CountCodepoints(value) catch unreachable;
653     if (@as(f32, @floatFromInt(glyphs)) * size * 0.62 > width) {
654         try writer.print(
655             " textLength=\"{d:.2}\" lengthAdjust=\"spacingAndGlyphs\"",
656             .{width},
657         );
658     }
659     try writer.writeByte('>');
660     try writeEscaped(writer, value);
661     try writer.writeAll("</text>");
662 }
663 
664 fn writeAccessibleName(
665     writer: *std.Io.Writer,
666     node: model.Node,
667 ) std.Io.Writer.Error!void {
668     if (node.title.len > 0) return writeEscaped(writer, node.title);
669     if (node.badge.len > 0) {
670         try writeEscaped(writer, node.badge);
671         try writer.writeByte('.');
672     }
673     if (node.context.len > 0) {
674         try writeEscaped(writer, node.context);
675         try writer.writeByte('.');
676     }
677     try writeEscaped(writer, node.label);
678 }
679 
680 fn writeEscaped(
681     writer: *std.Io.Writer,
682     value: []const u8,
683 ) std.Io.Writer.Error!void {
684     var escaped: [escaped_bytes_max]u8 = undefined;
685     const length = html.escapedLength(value) catch unreachable;
686     std.debug.assert(length <= escaped.len);
687     try writer.writeAll(html.writeEscaped(&escaped, value));
688 }