// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const std = @import("std"); const Allocator = std.mem.Allocator; const log = @import("../log.zig"); const parser = @import("../browser/netsurf.zig"); const AXNode = @This(); const Node = @import("Node.zig"); // Need a custom writer, because we can't just serialize the node as-is. // Sometimes we want to serializ the node without chidren, sometimes with just // its direct children, and sometimes the entire tree. // (For now, we only support direct children) pub const Writer = struct { root: *const Node, registry: *Node.Registry, const AXValuesType = enum(u8) { boolean, tristate, booleanOrUndefined, idref, idrefList, integer, node, nodeList, number, string, computedString, token, tokenList, domRelation, role, internalRole, valueUndefined }; pub const Opts = struct {}; pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void { self.toJSON(self.root, w) catch |err| { // The only error our jsonStringify method can return is // @TypeOf(w).Error. In other words, our code can't return its own // error, we can only return a writer error. Kinda sucks. log.err(.cdp, "node toJSON stringify", .{ .err = err }); return error.WriteFailed; }; } fn toJSON(self: *const Writer, node: *const Node, w: anytype) !void { try w.beginArray(); if (try self.writeNode(node, w)) { // skip children try w.endArray(); return; } // special case: if the node is the document, walk starts from body. const start = blk: { if (parser.nodeType(node._node) != .document) { break :blk node._node; } const doc = parser.documentHTMLToDocument(@ptrCast(node._node)); std.debug.assert(doc.is_html); // find tag const list = try parser.documentGetElementsByTagName(doc, "body"); break :blk parser.nodeListItem(list, 0) orelse node._node; }; const walker = Walker{}; var next: ?*parser.Node = null; var skip_children = false; while (true) { next = try walker.get_next(start, next, .{ .skip_children = skip_children }) orelse break; if (parser.nodeType(next.?) != .element) { skip_children = true; continue; } const n = try self.registry.register(next.?); skip_children = try self.writeNode(n, w); } try w.endArray(); } const AXValue = struct { type: enum(u8) { boolean, tristate, booleanOrUndefined, idref, idrefList, integer, node, nodeList, number, string, computedString, token, tokenList, domRelation, role, internalRole, valueUndefined }, value: ?[]const u8 = null, // TODO relatedNodes source: ?AXSource = null, }; fn writeAXSource(_: *const Writer, source: AXSource, w: anytype) !void { try w.objectField("sources"); try w.beginArray(); try w.beginObject(); // attribute, implicit, style, contents, placeholder, relatedElement const source_type = switch (source) { .aria_labelledby => blk: { try w.objectField("attribute"); try w.write(@tagName(source)); break :blk "relatedElement"; }, .aria_label, .alt, .title, .placeholder, .value => blk: { // No sure if it's correct for .value case. try w.objectField("attribute"); try w.write(@tagName(source)); break :blk "attribute"; }, // Chrome sends the content AXValue *again* in the source. // But It seems useless to me. // // w.objectField("value"); // self.writeAXValue(.{ .type = .computedString, .value = value.value }, w); .contents => "contents", .label_element, .label_wrap => "TODO", // TODO }; try w.objectField("type"); try w.write(source_type); try w.endObject(); try w.endArray(); } fn writeAXValue(self: *const Writer, value: AXValue, w: anytype) !void { try w.beginObject(); try w.objectField("type"); try w.write(@tagName(value.type)); if (value.value) |v| { try w.objectField("value"); try w.write(v); } if (value.source) |source| { try self.writeAXSource(source, w); } try w.endObject(); } const AXProperty = struct { name: enum(u8) { actions, busy, disabled, editable, focusable, focused, hidden, hiddenRoot, invalid, keyshortcuts, settable, roledescription, live, atomic, relevant, root, autocomplete, hasPopup, level, multiselectable, orientation, multiline, readonly, required, valuemin, valuemax, valuetext, checked, expanded, modal, pressed, selected, activedescendant, controls, describedby, details, errormessage, flowto, labelledby, owns, url, activeFullscreenElement, activeModalDialog, activeAriaModalDialog, ariaHiddenElement, ariaHiddenSubtree, emptyAlt, emptyText, inertElement, inertSubtree, labelContainer, labelFor, notRendered, notVisible, presentationalRole, probablyPresentational, inactiveCarouselTabContent, uninteresting }, value: AXValue, }; fn writeAXProperty(self: *const Writer, value: AXProperty, w: anytype) !void { try w.beginObject(); try w.objectField("name"); try w.write(@tagName(value.name)); try w.objectField("value"); try self.writeAXValue(value.value, w); try w.endObject(); } // write a node. returns true if children must be skipped. fn writeNode(self: *const Writer, node: *const Node, w: anytype) !bool { try w.beginObject(); const axn = try AXNode.fromNode(node._node); try w.objectField("nodeId"); try w.write(node.id); const ignore = try axn.isIgnore(); try w.objectField("ignored"); try w.write(ignore); if (ignore) { try w.objectField("ignored_reasons"); try w.beginArray(); try w.beginObject(); try w.objectField("name"); try w.write("uninteresting"); try w.objectField("value"); try self.writeAXValue(.{ .type = .boolean, .value = "true" }, w); try w.endObject(); try w.endArray(); } try w.objectField("role"); try self.writeAXValue(.{ .type = .role, .value = try axn.getRole() }, w); if (!ignore) { try w.objectField("name"); try w.beginObject(); try w.objectField("type"); try w.write(@tagName(.computedString)); try w.objectField("value"); const source = try axn.writeName(w); if (source) |s| { try self.writeAXSource(s, w); } try w.endObject(); } const n = axn._node; if (parser.nodeParentNode(n)) |p| { const parent_node = try self.registry.register(p); try w.objectField("parentId"); try w.write(parent_node.id); } // Children try w.objectField("childIds"); var registry = self.registry; const child_nodes = try parser.nodeGetChildNodes(n); const child_count = parser.nodeListLength(child_nodes); var i: usize = 0; try w.beginArray(); for (0..child_count) |_| { defer i += 1; const child = (parser.nodeListItem(child_nodes, @intCast(i))) orelse break; // TODO ignore some of them? const child_node = try registry.register(child); try w.write(child_node.id); } try w.endArray(); try w.endObject(); return false; } }; pub const AXRole = enum(u8) { none, article, banner, blockquote, button, caption, cell, checkbox, code, columnheader, combobox, complementary, contentinfo, definition, deletion, dialog, document, emphasis, figure, form, group, heading, img, insertion, link, list, listbox, listitem, main, marquee, meter, navigation, option, paragraph, presentation, progressbar, radio, region, row, rowgroup, rowheader, searchbox, separator, slider, spinbutton, status, strong, subscript, superscript, table, term, textbox, time, fn fromNode(node: *parser.Node) !AXRole { switch (parser.nodeType(node)) { .document => return .document, .element => {}, else => { log.debug(.cdp, "invalid tag", .{ .node_type = parser.nodeType(node) }); return error.InvalidTag; }, } const elt: *parser.Element = @ptrCast(node); const tag = try parser.elementTag(elt); return switch (tag) { // Navigation & Structure .nav => .navigation, .main => .main, .aside => .complementary, // TODO conditions: // .banner Not descendant of article, aside, main, nav, section // (none) When descendant of article, aside, main, nav, section .header => .banner, // TODO conditions: // contentinfo Not descendant of article, aside, main, nav, section // (none) When descendant of article, aside, main, nav, section .footer => .contentinfo, // TODO conditions: // region Has accessible name (aria-label, aria-labelledby, or title) | // (none) No accessible name | .section => .region, .article, .hgroup => .article, .address => .group, // Headings .h1, .h2, .h3, .h4, .h5, .h6 => .heading, .ul, .ol, .menu => .list, .li => .listitem, .dt => .term, .dd => .definition, // Forms & Inputs // TODO conditions: // form Has accessible name // (none) No accessible name .form => .form, .input => { const input_type = try parser.inputGetType(@ptrCast(elt)); switch (input_type.len) { 3 => { // tel defaults to textbox // url defaults to textbox }, 4 => { if (std.ascii.eqlIgnoreCase(input_type, "date")) { return .none; } if (std.ascii.eqlIgnoreCase(input_type, "file")) { return .none; } if (std.ascii.eqlIgnoreCase(input_type, "time")) { return .none; } if (std.ascii.eqlIgnoreCase(input_type, "week")) { return .none; } // text defaults to textbox }, 5 => { if (std.ascii.eqlIgnoreCase(input_type, "color")) { return .none; } if (std.ascii.eqlIgnoreCase(input_type, "image")) { return .button; } if (std.ascii.eqlIgnoreCase(input_type, "month")) { return .none; } if (std.ascii.eqlIgnoreCase(input_type, "radio")) { return .radio; } if (std.ascii.eqlIgnoreCase(input_type, "range")) { return .slider; } if (std.ascii.eqlIgnoreCase(input_type, "reset")) { return .button; } // email defaults to textbox. }, 6 => { if (std.ascii.eqlIgnoreCase(input_type, "button")) { return .button; } if (std.ascii.eqlIgnoreCase(input_type, "hidden")) { return .none; } if (std.ascii.eqlIgnoreCase(input_type, "number")) { return .spinbutton; } if (std.ascii.eqlIgnoreCase(input_type, "search")) { return .searchbox; } if (std.ascii.eqlIgnoreCase(input_type, "submit")) { return .button; } }, 8 => { if (std.ascii.eqlIgnoreCase(input_type, "checkbox")) { return .checkbox; } if (std.ascii.eqlIgnoreCase(input_type, "password")) { return .none; } }, 14 => { if (std.ascii.eqlIgnoreCase(input_type, "datetime-local")) { return .none; } }, else => {}, } return .textbox; }, .textarea => .textbox, .select => { if (try getAttribute(node, "multiple") != null) { return .listbox; } if (try getAttribute(node, "size")) |size| { if (!std.ascii.eqlIgnoreCase(size, "1")) { return .listbox; } } return .combobox; }, .option => .option, .optgroup, .fieldset => .group, .button => .button, .output => .status, .progress => .progressbar, .meter => .meter, .datalist => .listbox, // Interactive Elements .a, .area => { if (try getAttribute(node, "href") == null) { return .none; } return .link; }, .details => .group, .summary => .button, .dialog => .dialog, // Media .img => .img, .figure => .figure, // Tables .table => .table, .caption => .caption, .thead, .tbody, .tfoot => .rowgroup, .tr => .row, .th => { if (try getAttribute(node, "scope")) |scope| { if (std.ascii.eqlIgnoreCase(scope, "row")) { return .rowheader; } } return .columnheader; }, .td => .cell, // Text & Semantics .p => .paragraph, .hr => .separator, .blockquote => .blockquote, .code => .code, .em => .emphasis, .strong => .strong, .s, .del => .deletion, .ins => .insertion, .sub => .subscript, .sup => .superscript, .time => .time, .dfn => .term, // Document Structure .html => .document, .body => .document, // Deprecated/Obsolete Elements .marquee => .marquee, else => .none, }; } }; _node: *parser.Node, role_attr: ?[]const u8, pub fn fromNode(node: *parser.Node) !AXNode { return .{ ._node = node, .role_attr = try getAttribute(node, "role"), }; } const AXSource = enum(u8) { aria_labelledby, aria_label, label_element, //