1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
|
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/renderer/accessibility/read_anything/read_anything_node_utils.h"
#include <cinttypes>
#include "base/strings/stringprintf.h"
#include "ui/accessibility/ax_enum_util.h"
#include "ui/accessibility/ax_role_properties.h"
#include "ui/base/l10n/l10n_util.h"
namespace a11y {
bool IsSuperscript(const ui::AXNode* ax_node) {
return ax_node->data().GetTextPosition() ==
ax::mojom::TextPosition::kSuperscript;
}
bool IsTextForReadAnything(const ui::AXNode* node, bool is_pdf, bool is_docs) {
if (!node) {
return false;
}
// ListMarkers will have an HTML tag of "::marker," so they won't be
// considered text when checking for the length of the html tag. However, in
// order to read out loud ordered bullets, nodes that have the kListMarker
// role should be included.
// Note: This technically will include unordered list markers like bullets,
// but these won't be spoken because they will be filtered by the TTS engine.
bool is_list_marker = node->GetRole() == ax::mojom::Role::kListMarker;
// TODO(crbug.com/40927698): Can this be updated to IsText() instead of
// checking the length of the html tag?
return (GetHtmlTag(node, is_pdf, is_docs).length() == 0) || is_list_marker;
}
bool IsIgnored(const ui::AXNode* const ax_node, bool is_pdf) {
if (ax_node->IsIgnored()) {
return true;
}
// PDFs processed with OCR have additional nodes that mark the start and end
// of a page. The start of a page is indicated with a `kBanner` node that has
// a child static text node. Ignore both. The end of a page is indicated with
// a `kContentInfo` node that has a child static text node. Ignore the static
// text node but keep the `kContentInfo` so a line break can be inserted in
// between pages during `a11y::GetHtmlTagForPDF()`.
const ax::mojom::Role role = ax_node->GetRole();
if (is_pdf) {
// The text content of the aforementioned `kBanner` or `kContentInfo` node
// is the same as the text content of its child static text node.
const ui::AXNode* const parent = ax_node->GetParent();
if (const std::string_view text = ax_node->GetTextContentUTF8();
text == l10n_util::GetStringUTF8(IDS_PDF_OCR_RESULT_BEGIN)) {
if (role == ax::mojom::Role::kBanner ||
(parent && parent->GetRole() == ax::mojom::Role::kBanner)) {
return true;
}
} else if (text == l10n_util::GetStringUTF8(IDS_PDF_OCR_RESULT_END) &&
parent && parent->GetRole() == ax::mojom::Role::kContentInfo) {
return true;
}
}
// Ignore interactive elements, except for text fields and aria-related
// support fields.
return (ui::IsControl(role) && !ui::IsTextField(role)) || ui::IsSelect(role);
}
std::string GetHtmlTag(const ui::AXNode* ax_node, bool is_pdf, bool is_docs) {
std::string html_tag =
ax_node->GetStringAttribute(ax::mojom::StringAttribute::kHtmlTag);
if (is_pdf) {
return GetHtmlTagForPDF(ax_node, html_tag);
}
if (ui::IsTextField(ax_node->GetRole())) {
return "div";
}
if (ui::IsHeading(ax_node->GetRole())) {
int32_t hierarchical_level =
ax_node->GetIntAttribute(ax::mojom::IntAttribute::kHierarchicalLevel);
if (hierarchical_level) {
return base::StringPrintf("h%" PRId32, hierarchical_level);
}
}
if (html_tag == ui::ToString(ax::mojom::Role::kMark)) {
// Replace mark element with bold element for readability.
html_tag = "b";
} else if (is_docs) {
// Change HTML tags for SVG elements to allow Reading Mode to render text
// for the Annotated Canvas elements in a Google Doc.
if (html_tag == "svg") {
html_tag = "div";
}
if (html_tag == "g" && ax_node->GetRole() == ax::mojom::Role::kParagraph) {
html_tag = "p";
}
}
return html_tag;
}
std::string GetHtmlTagForPDF(const ui::AXNode* ax_node,
const std::string& html_tag) {
ax::mojom::Role role = ax_node->GetRole();
// Some nodes in PDFs don't have an HTML tag so use role instead.
switch (role) {
case ax::mojom::Role::kEmbeddedObject:
case ax::mojom::Role::kRegion:
case ax::mojom::Role::kPdfRoot:
case ax::mojom::Role::kRootWebArea:
return "span";
case ax::mojom::Role::kParagraph:
return "p";
case ax::mojom::Role::kLink:
return "a";
case ax::mojom::Role::kStaticText:
return "";
case ax::mojom::Role::kHeading:
return GetHeadingHtmlTagForPDF(ax_node, html_tag);
// Add a line break after each page of an inaccessible PDF for readability
// since there is no other formatting included in the OCR output.
case ax::mojom::Role::kContentInfo:
if (ax_node->GetTextContentUTF8() ==
l10n_util::GetStringUTF8(IDS_PDF_OCR_RESULT_END)) {
return "br";
}
[[fallthrough]];
default:
return html_tag.empty() ? "span" : html_tag;
}
}
std::string GetHeadingHtmlTagForPDF(const ui::AXNode* ax_node,
const std::string& html_tag) {
// Sometimes whole paragraphs can be formatted as a heading. If the text is
// longer than 2 lines, assume it was meant to be a paragragh.
// LINT.IfChange(MaxLineWidth)
static constexpr int kMaxLineWidth = 60;
// LINT.ThenChange(//chrome/browser/resources/side_panel/read_anything/app.css:MaxLineWidth)
if (ax_node->GetTextContentLengthUTF8() > (2 * kMaxLineWidth)) {
return "p";
}
// A single block of text could be incorrectly formatted with multiple heading
// nodes (one for each line of text) instead of a single paragraph node. This
// case should be detected to improve readability. If there are multiple
// consecutive nodes with the same heading level, assume that they are all a
// part of one paragraph.
ui::AXNode* next = ax_node->GetNextUnignoredSibling();
ui::AXNode* prev = ax_node->GetPreviousUnignoredSibling();
if ((next && next->GetStringAttribute(ax::mojom::StringAttribute::kHtmlTag) ==
html_tag) ||
(prev && prev->GetStringAttribute(ax::mojom::StringAttribute::kHtmlTag) ==
html_tag)) {
return "span";
}
int32_t hierarchical_level =
ax_node->GetIntAttribute(ax::mojom::IntAttribute::kHierarchicalLevel);
if (hierarchical_level) {
return base::StringPrintf("h%" PRId32, hierarchical_level);
}
return html_tag;
}
std::string GetAltText(const ui::AXNode* ax_node) {
std::string alt_text =
ax_node->GetStringAttribute(ax::mojom::StringAttribute::kName);
return alt_text;
}
std::string GetImageDataUrl(const ui::AXNode* ax_node) {
std::string url =
ax_node->GetStringAttribute(ax::mojom::StringAttribute::kImageDataUrl);
return url;
}
std::u16string GetTextContent(const ui::AXNode* ax_node,
bool is_docs,
bool is_pdf) {
// For Google Docs, because the content is rendered in canvas, we distill
// text from the "Annotated Canvas"
// (https://sites.google.com/corp/google.com/docs-canvas-migration/home)
// instead of the HTML.
if (is_docs) {
// With 'Annotated Canvas', text is stored within the aria-labels of SVG
// elements. To retrieve this text, we need to access the 'name' attribute
// of these elements.
if (!ax_node->GetTextContentLengthUTF16()) {
std::u16string nodeText = GetNameAttributeText(ax_node);
if (!nodeText.empty()) {
// Add a space between the text of two annotated canvas elements.
// Otherwise, there is no space separating two lines of text.
return nodeText + u" ";
}
} else {
// We ignore all text in the HTML. These text are either from comments or
// from off-screen divs that contain hidden information information that
// only is intended for screen readers and braille support. These are not
// actual text in the doc.
// TODO(b/324143642): Reading Mode handles Doc comments.
if (ax_node->GetRole() == ax::mojom::Role::kStaticText) {
return u"";
}
}
}
// TODO(crbug.com//40927698): Investigate how we can remove this. Possibly by
// improving distillation for pdfs.
if (is_pdf) {
std::u16string filtered_string(ax_node->GetTextContentUTF16());
// When we receive text from a pdf node, there are return characters at each
// visual line break in the page. If these aren't filtered, one of two
// things could happen:
// 1) part of the same sentence will be read as separate segments, causing
// choppy speech (e.g. without filtering, 'This is a long sentence with
// \n\r a line break.' will read and highlight "This is a long sentence
// with" and "a line break" separately.
// 2) parts of the sentence are not highlighted at all because GetNextWord
// using accessible text boundaries continues returning the line break
// infinitely (and we thus break out of the infinite loop and instead
// highlight nothing).
if (is_pdf && filtered_string.size() > 0) {
size_t pos = filtered_string.find_first_of(u"\n\r");
while (pos != std::string::npos && pos < filtered_string.size() - 2) {
filtered_string.replace(pos, 1, u" ");
pos = filtered_string.find_first_of(u"\n\r");
}
}
return filtered_string;
}
return ax_node->GetTextContentUTF16();
}
std::u16string GetNameAttributeText(const ui::AXNode* ax_node) {
DCHECK(ax_node);
std::u16string node_text;
if (ax_node->HasStringAttribute(ax::mojom::StringAttribute::kName)) {
node_text =
ax_node->GetString16Attribute(ax::mojom::StringAttribute::kName);
}
for (auto it = ax_node->UnignoredChildrenBegin();
it != ax_node->UnignoredChildrenEnd(); ++it) {
if (node_text.empty()) {
node_text = GetNameAttributeText(it.get());
} else {
node_text += u" " + GetNameAttributeText(it.get());
}
}
return node_text;
}
} // namespace a11y
|