blob: 56845a4d91a267602b396dbd8cf55fe44478aaf6 [file] [log] [blame]
// Copyright 2017 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#include "cobalt/accessibility/internal/text_alternative_helper.h"
#include <utility>
#include "base/string_split.h"
#include "cobalt/dom/document.h"
#include "cobalt/dom/html_element.h"
#include "cobalt/dom/html_image_element.h"
#include "cobalt/dom/node_list.h"
namespace cobalt {
namespace accessibility {
namespace internal {
namespace {
// Helper class that inserts a base::Token into a base::hash_set during the
// class's lifetime.
class ScopedIdSetMember {
public:
// Adds |token| into |hash_set| for the duration of this class instance's
// lifetime. If |token| is an empty string, it doesn't get added to the set.
//
// TextAlternativeHelper::visited_element_ids_ is used to keep track of a set
// of element ids that have been visited during the current computation to
// ensure that we do not follow any ID reference loops. When
// AppendTextAlternative is called, the ID of that element is pushed into the
// set using this helper class, so that the ID will be popped back out of the
// set which allows us to re-visit that element in a non-recursive manner, if
// necessary.
ScopedIdSetMember(base::hash_set<base::Token>* hash_set,
const base::Token& token)
: hash_set_(hash_set) {
// Ignore empty tokens.
if (SbStringGetLength(token.c_str()) > 0) {
DCHECK(hash_set_->find(token) == hash_set_->end());
iterator_pair_ = hash_set_->insert(token);
DCHECK(iterator_pair_.second);
} else {
// Ensure iterator_pair_ is initialized.
iterator_pair_ = std::make_pair(hash_set_->end(), false);
}
}
~ScopedIdSetMember() {
if (iterator_pair_.second) {
hash_set_->erase(iterator_pair_.first);
}
}
private:
base::hash_set<base::Token>* hash_set_;
std::pair<base::hash_set<base::Token>::iterator, bool> iterator_pair_;
};
// Look up an element whose id is |id| in the document that |element| is in.
scoped_refptr<dom::Element> GetElementById(
const scoped_refptr<dom::Element>& element, const std::string& id) {
// TODO: If referenced elements tend to be close to the referencing element,
// it may be more efficient to do a breadth-first search starting from
// |element|.
dom::Document* document = element->node_document();
scoped_refptr<dom::Element> target_element = document->GetElementById(id);
DLOG_IF(WARNING, !target_element) << "Could not find aria-labelledby target: "
<< id;
return target_element;
}
} // namespace
void TextAlternativeHelper::AppendTextAlternative(
const scoped_refptr<dom::Node>& node) {
DCHECK(node);
// https://www.w3.org/TR/2014/REC-wai-aria-implementation-20140320/#mapping_additional_nd_te
// 5.6.1.3. Text Alternative Computation
if (node->IsText()) {
// Rule 3D
// Get the contents of the text node.
if (node->text_content()) {
AppendTextIfNonEmpty(*node->text_content());
}
return;
}
scoped_refptr<dom::Element> element = node->AsElement();
if (!element) {
// Since this is neither a Text or Element, ignore it.
return;
}
if (visited_element_ids_.find(element->id()) != visited_element_ids_.end()) {
DLOG(INFO) << "Skipping element to prevent reference loop: "
<< element->id();
return;
}
ScopedIdSetMember scoped_id_set_member(&visited_element_ids_, element->id());
// Rule 1
// Skip hidden elements unless the author specifies to use them via an
// aria-labelledby or aria-describedby being used in the current computation.
if (!in_labelled_by_or_described_by_ && IsAriaHidden(element)) {
return;
}
// Rule 2A (first bullet point)
// The aria-labelledby attribute takes precedence as the element's text
// alternative unless this computation is already occurring as the result of a
// recursive aria-labelledby declaration.
if (!in_labelled_by_or_described_by_) {
if (TryAppendFromLabelledByOrDescribedBy(element,
base::Tokens::aria_labelledby())) {
return;
}
}
// Rule 2A (second bullet point)
// If aria-labelledby is empty or undefined, the aria-label attribute, which
// defines an explicit text string, is used. However, if this computation is
// already occurring as the result of a recursive text alternative computation
// and the current element is an embedded control as defined in rule 2B,
// ignore the aria-label attribute and skip directly to rule 2B.
//
// Note: Cobalt doesn't support the embedded controls defined in rule 2B, so
// just get the alternative text from the label, if it exists.
if (TryAppendFromLabel(element)) {
return;
}
// Rule 2A (third bullet point)
// If aria-labelledby and aria-label are both empty or undefined, and if the
// element is not marked as presentational (role="presentation", check for the
// presence of an equivalent host language attribute or element for
// associating a label, and use those mechanisms to determine a text
// alternative. For example, in HTML, the img element's alt attribute defines
// a label string and the label element references the form element it labels.
//
// This implementation does not support the "role" attribute, so just get the
// "alt" property, if this is an element that supports it
if (TryAppendFromAltProperty(element)) {
return;
}
// Rule 2B
// Cobalt does not support the controls described in this step, so skip it.
// Rule 2C
// Otherwise, if the attributes checked in rules A and B didn't provide
// results, text is collected from descendant content if the current element's
// role allows "Name From: contents." The text alternatives for child nodes
// will be concatenated, using this same set of rules. This same rule may
// apply to a child, which means the computation becomes recursive and can
// result in text being collected in all the nodes in this subtree, no matter
// how deep they are. However, any given descendant subtree may instead
// collect their part of the text alternative from the preferred markup
// described in A and B above. These author-specified attributes are assumed
// to provide the correct text alternative for the entire subtree. All in all,
// the node rules are applied consistently as text alternatives are collected
// from descendants, and each containing element in those descendants may or
// may not allow their contents to be used. Each node in the subtree is
// consulted only once. If text has been collected from a child node, and is
// referenced by another IDREF in some descendant node, then that second, or
// subsequent, reference is not followed. This is done to avoid infinite
// loops.
//
// The "role" attribute is not supported, so ignore it. Recurse through
// children and concatenate text alternatives.
scoped_refptr<dom::NodeList> children = element->child_nodes();
for (int i = 0; i < children->length(); ++i) {
scoped_refptr<dom::Node> child = children->Item(i);
AppendTextAlternative(child);
}
// 5.6.1.2. Description Compution
// If aria-describedby is present, user agents MUST compute the accessible
// description by concatenating the text alternatives for nodes referenced by
// an aria-describedby attribute on the current node. The text alternatives
// for the referenced nodes are computed using a number of methods.
if (!in_labelled_by_or_described_by_) {
TryAppendFromLabelledByOrDescribedBy(element,
base::Tokens::aria_describedby());
}
}
std::string TextAlternativeHelper::GetTextAlternative() {
return JoinString(alternatives_, ' ');
}
bool TextAlternativeHelper::IsAriaHidden(
const scoped_refptr<dom::Element>& element) {
if (!element) {
return false;
}
base::optional<std::string> aria_hidden_attribute =
element->GetAttribute(base::Tokens::aria_hidden().c_str());
if (aria_hidden_attribute.value_or("") == base::Tokens::true_token()) {
return true;
}
return IsAriaHidden(element->parent_element());
}
bool TextAlternativeHelper::TryAppendFromLabelledByOrDescribedBy(
const scoped_refptr<dom::Element>& element, const base::Token& token) {
DCHECK(!in_labelled_by_or_described_by_);
base::optional<std::string> attributes = element->GetAttribute(token.c_str());
std::vector<std::string> ids;
// If aria-labelledby is empty or undefined, the aria-label attribute ... is
// used (defined below).
base::SplitStringAlongWhitespace(attributes.value_or(""), &ids);
const size_t current_num_alternatives = alternatives_.size();
if (!ids.empty()) {
in_labelled_by_or_described_by_ = true;
for (int i = 0; i < ids.size(); ++i) {
if (visited_element_ids_.find(base::Token(ids[i])) !=
visited_element_ids_.end()) {
DLOG(WARNING) << "Skipping reference to ID: " << ids[i]
<< " to prevent reference loop.";
continue;
}
scoped_refptr<dom::Element> target_element =
GetElementById(element, ids[i]);
if (!target_element) {
DLOG(WARNING) << "Could not find " << token.c_str()
<< " target: " << ids[i];
continue;
}
AppendTextAlternative(target_element);
}
in_labelled_by_or_described_by_ = false;
}
// Check if any of these recursive calls to AppendTextAlternative actually
// ended up appending something.
return current_num_alternatives != alternatives_.size();
}
bool TextAlternativeHelper::TryAppendFromLabel(
const scoped_refptr<dom::Element>& element) {
base::optional<std::string> label_attribute =
element->GetAttribute(base::Tokens::aria_label().c_str());
return AppendTextIfNonEmpty(label_attribute.value_or(""));
}
bool TextAlternativeHelper::TryAppendFromAltProperty(
const scoped_refptr<dom::Element>& element) {
// https://www.w3.org/TR/REC-html40/struct/objects.html#h-13.8
// The only element type that supports the "alt" attribute that Cobalt
// implements is the <img> element.
if (element->AsHTMLElement() &&
element->AsHTMLElement()->AsHTMLImageElement()) {
base::optional<std::string> alt_attribute =
element->GetAttribute(base::Tokens::alt().c_str());
if (alt_attribute) {
return AppendTextIfNonEmpty(*alt_attribute);
}
}
return false;
}
bool TextAlternativeHelper::AppendTextIfNonEmpty(const std::string& text) {
std::string trimmed;
TrimWhitespaceASCII(text, TRIM_ALL, &trimmed);
if (!trimmed.empty()) {
alternatives_.push_back(trimmed);
}
return !trimmed.empty();
}
} // namespace internal
} // namespace accessibility
} // namespace cobalt