blob: 0a0c79868b325bc988ae51ffd36e9e46a880a8ea [file]
/*
* Copyright (C) 2025 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of Apple Inc. ("Apple") nor the names of
* its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
#include "config.h"
#include "AXLiveRegionManager.h"
#if PLATFORM(COCOA)
#include "AXNotifications.h"
#include "AXObjectCache.h"
#include "AccessibilityObject.h"
#include <wtf/TZoneMallocInlines.h>
#include <wtf/text/CharacterProperties.h>
namespace WebCore {
WTF_MAKE_TZONE_ALLOCATED_IMPL(AXLiveRegionManager);
#if PLATFORM(MAC)
static constexpr ASCIILiteral accessibilityLanguageAttributeKey = "AXLanguage"_s;
static constexpr ASCIILiteral accessibilityIsLiveRegionRemovalAttributeKey = "AXIsLiveRegionRemoval"_s;
#else
static constexpr ASCIILiteral accessibilityLanguageAttributeKey = "UIAccessibilitySpeechAttributeLanguage"_s;
static constexpr ASCIILiteral accessibilityIsLiveRegionRemovalAttributeKey = "UIAccessibilityTokenIsLiveRegionRemoval"_s;
#endif
struct LiveRegionObjectMetadata {
String text;
String language;
HashSet<AXID> descendants;
};
AXLiveRegionManager::AXLiveRegionManager(AXObjectCache& cache)
: m_cache(cache)
, m_emptyRegionSettleTimer(*this, &AXLiveRegionManager::emptyRegionSettleTimerFired)
{
}
static UNUSED_FUNCTION String debugDescriptionForSnapshot(LiveRegionSnapshot snapshot)
{
StringBuilder result;
result.append("SNAPSHOT:\n"_s);
result.append("\tStatus: "_s);
switch (snapshot.liveRegionStatus) {
case LiveRegionStatus::Off:
result.append("Off"_s);
break;
case LiveRegionStatus::Polite:
result.append("Polite"_s);
break;
case LiveRegionStatus::Assertive:
result.append("Assertive"_s);
break;
}
result.append('\n');
result.append("\tRelevant: "_s);
if (snapshot.liveRegionRelevant.isEmpty())
result.append("(default: additions text)"_s);
else {
bool isFirst = true;
if (snapshot.liveRegionRelevant.contains(LiveRegionRelevant::Additions)) {
result.append("additions"_s);
isFirst = false;
}
if (snapshot.liveRegionRelevant.contains(LiveRegionRelevant::Removals)) {
if (!isFirst)
result.append(' ');
result.append("removals"_s);
isFirst = false;
}
if (snapshot.liveRegionRelevant.contains(LiveRegionRelevant::Text)) {
if (!isFirst)
result.append(' ');
result.append("text"_s);
isFirst = false;
}
if (snapshot.liveRegionRelevant.contains(LiveRegionRelevant::All)) {
if (!isFirst)
result.append(' ');
result.append("all"_s);
}
}
result.append('\n');
result.append("\tObjects: "_s);
result.append(snapshot.objects.size());
result.append('\n');
for (size_t i = 0; i < snapshot.objects.size(); ++i) {
const auto& object = snapshot.objects[i];
result.append("\t\t["_s);
result.append(i);
result.append("] AXID="_s);
result.append(object.objectID.loggingString());
result.append(" text=\""_s);
result.append(object.text);
result.append("\"\n"_s);
}
return result.toString();
}
void AXLiveRegionManager::registerLiveRegion(AccessibilityObject& object, bool speakIfNecessary)
{
m_liveRegions.set(object.objectID(), buildLiveRegionSnapshot(object));
// Alerts should speak when added to the page (or initialized for the first time), unlike all other live regions.
bool isAlertOrAlertDialog = speakIfNecessary && (object.role() == AccessibilityRole::ApplicationAlert || object.role() == AccessibilityRole::ApplicationAlertDialog);
if (isAlertOrAlertDialog)
handleLiveRegionChange(object, AnnouncementContents::All);
}
static LiveRegionStatus stringToLiveRegionStatus(const String& string)
{
if (equalLettersIgnoringASCIICase(string, "assertive"_s))
return LiveRegionStatus::Assertive;
if (equalLettersIgnoringASCIICase(string, "polite"_s))
return LiveRegionStatus::Polite;
return LiveRegionStatus::Off;
}
static OptionSet<LiveRegionRelevant> stringToLiveRegionRelevant(const String& string)
{
OptionSet<LiveRegionRelevant> result;
for (auto attribute : StringView(string).split(' ')) {
if (equalLettersIgnoringASCIICase(attribute, "additions"_s))
result.add(LiveRegionRelevant::Additions);
else if (equalLettersIgnoringASCIICase(attribute, "all"_s))
result.add(LiveRegionRelevant::All);
else if (equalLettersIgnoringASCIICase(attribute, "removals"_s))
result.add(LiveRegionRelevant::Removals);
else if (equalLettersIgnoringASCIICase(attribute, "text"_s))
result.add(LiveRegionRelevant::Text);
}
return result;
}
// How long a live region has to stay empty before the clear is treated as something the page meant,
// rather than the gap between tearing the region down and rebuilding it.
static constexpr Seconds emptyRegionSettleDelay { 100_ms };
// Whether two snapshots hold the same text laid out the same way.
static bool hasSameObjectTexts(const LiveRegionSnapshot& oldSnapshot, const LiveRegionSnapshot& newSnapshot)
{
if (oldSnapshot.objects.size() != newSnapshot.objects.size())
return false;
for (size_t i = 0; i < oldSnapshot.objects.size(); ++i) {
if (oldSnapshot.objects[i].text != newSnapshot.objects[i].text)
return false;
}
return true;
}
void AXLiveRegionManager::handleLiveRegionChange(AccessibilityObject& object, AnnouncementContents contents)
{
// If this is a new live region, don't speak it upon registering.
auto iterator = m_liveRegions.find(object.objectID());
if (iterator == m_liveRegions.end()) {
registerLiveRegion(object);
return;
}
LiveRegionSnapshot oldSnapshot = contents == AnnouncementContents::All ? LiveRegionSnapshot { } : iterator->value;
bool baselineHadText = iterator->value.hasAnyText;
bool baselineSettledEmpty = iterator->value.settledEmpty;
LiveRegionSnapshot newSnapshot = buildLiveRegionSnapshot(object);
// Streaming pages often re-render a region by emptying it and refilling it in a later task, so this
// empty state is transient rather than a change. Adopting it as the baseline would make the refilled
// text look entirely new, so instead, only keep the last baseline that had text.
if (!newSnapshot.hasAnyText && baselineHadText) {
m_regionsPendingEmpty.add(object.objectID());
if (!m_emptyRegionSettleTimer.isActive())
m_emptyRegionSettleTimer.startOneShot(emptyRegionSettleDelay);
// The baseline is kept, but a region that asked to hear removals still has to hear that its
// content went away.
if (newSnapshot.liveRegionRelevant.containsAny({ LiveRegionRelevant::Removals, LiveRegionRelevant::All })) {
// Re-find rather than reusing the iterator, which building the snapshot above may have invalidated.
auto baseline = m_liveRegions.find(object.objectID());
if (baseline != m_liveRegions.end() && !baseline->value.announcedEmptyRemoval) {
baseline->value.announcedEmptyRemoval = true;
postAnnouncementForChange(object, oldSnapshot, newSnapshot);
}
}
return;
}
// Content is present, so whatever empty state was being waited on was only part of a render.
m_regionsPendingEmpty.remove(object.objectID());
// The region was cleared, stayed cleared, and has now come back saying exactly what it said before,
// split across objects in the same way. Reset the old snapshot so an announcement can be made.
if (baselineSettledEmpty && hasSameObjectTexts(oldSnapshot, newSnapshot))
oldSnapshot = LiveRegionSnapshot { };
// Re-find rather than reusing the iterator, which building the snapshot above may have invalidated.
m_liveRegions.set(object.objectID(), newSnapshot);
postAnnouncementForChange(object, oldSnapshot, newSnapshot);
}
// Nothing came back while the timer ran, so these regions were cleared rather than caught mid-render.
void AXLiveRegionManager::emptyRegionSettleTimerFired()
{
for (auto axID : std::exchange(m_regionsPendingEmpty, { })) {
if (auto iterator = m_liveRegions.find(axID); iterator != m_liveRegions.end())
iterator->value.settledEmpty = true;
}
}
// Cap on the aggregated text, so that content made of a few enormous nodes cannot overflow the builder
// or make comparison arbitrarily expensive. Exceeding it truncates the snapshot rather than failing.
static constexpr size_t maximumAggregateLength = 131072;
// Concatenates the objects' text, separated by a single space, with each object's own whitespace runs
// collapsed and its leading and trailing whitespace dropped.
static String aggregateText(const Vector<LiveRegionObject>& objects, bool& isTruncated, Vector<size_t>* objectEndOffsets = nullptr)
{
size_t lengthHint = 0;
for (auto& object : objects)
lengthHint += object.text.length() + 1;
StringBuilder builder;
builder.reserveCapacity(std::min(lengthHint, maximumAggregateLength + 1));
if (objectEndOffsets)
objectEndOffsets->reserveInitialCapacity(objects.size());
for (auto& object : objects) {
String text = object.text.simplifyWhiteSpace(isUnicodeWhitespace);
if (!text.isEmpty()) {
if (builder.length())
builder.append(' ');
builder.append(text);
}
if (builder.length() > maximumAggregateLength) {
isTruncated = true;
return { };
}
if (objectEndOffsets)
objectEndOffsets->append(builder.length());
}
return builder.toString();
}
// True when text carries no word of its own, which is not worth announcing. Resolving inline markup can
// split a trailing punctuation mark into its own object, which would otherwise be announced as just ".".
static bool isPunctuationOnly(const String& text)
{
for (unsigned i = 0; i < text.length(); ++i) {
char16_t character = text[i];
if (!isPunctuation(character) && !isUnicodeWhitespace(character))
return false;
}
return true;
}
// Limit on the number of objects visited during snapshot building to prevent
// web content from hanging the process with excessively large live regions.
static constexpr size_t maximumSnapshotObjects = 512;
LiveRegionSnapshot AXLiveRegionManager::buildLiveRegionSnapshot(AccessibilityObject& object) const
{
++m_snapshotBuildCount;
LiveRegionSnapshot snapshot;
snapshot.liveRegionStatus = stringToLiveRegionStatus(object.liveRegionStatus());
snapshot.liveRegionRelevant = stringToLiveRegionRelevant(object.liveRegionRelevant());
size_t objectsVisited = 0;
std::function<void(AccessibilityObject&)> buildObjectList = [protectedThis = CheckedRef { *this }, &buildObjectList, &snapshot, &objectsVisited] (AccessibilityObject& object) {
if (objectsVisited >= maximumSnapshotObjects) {
snapshot.isTruncated = true;
return;
}
++objectsVisited;
// Treat atomic objects as one object, so when they change the entire subtree is announced.
if (object.liveRegionAtomic()) {
HashSet<AXID> descendants;
// Collect all atomic-region descendants to detect when nodes are added/removed within the atomic region.
std::function<void(AccessibilityObject&)> collectDescendants = [&collectDescendants, &descendants, &objectsVisited, &snapshot] (AccessibilityObject& descendant) {
if (objectsVisited >= maximumSnapshotObjects) {
snapshot.isTruncated = true;
return;
}
++objectsVisited;
descendants.add(descendant.objectID());
for (auto& child : descendant.unignoredChildren())
collectDescendants(downcast<AccessibilityObject>(child.get()));
};
for (auto& child : object.unignoredChildren())
collectDescendants(downcast<AccessibilityObject>(child.get()));
String text = object.announcementText();
snapshot.hasAnyText |= !text.isEmpty();
snapshot.hasAtomicRegion = true;
snapshot.objects.append({ object.objectID(), WTF::move(text), object.languageIncludingAncestors(), WTF::move(descendants), /* isTextContent */ false });
return;
}
if (protectedThis->shouldIncludeInSnapshot(object)) {
String text = object.announcementText();
snapshot.hasAnyText |= !text.isEmpty();
snapshot.objects.append({ object.objectID(), WTF::move(text), object.languageIncludingAncestors(), { }, object.isStaticText() });
} else {
for (auto& child : object.unignoredChildren())
buildObjectList(downcast<AccessibilityObject>(child.get()));
}
};
buildObjectList(object);
if (!snapshot.isTruncated)
snapshot.aggregatedText = aggregateText(snapshot.objects, snapshot.isTruncated, &snapshot.objectEndOffsets);
return snapshot;
}
bool AXLiveRegionManager::shouldIncludeInSnapshot(AccessibilityObject& object) const
{
if (object.isStaticText())
return true;
// Description will account for alt text, aria-label(ledby), and title attributes.
if (String description = object.description(); description.length())
return true;
// If an object has unignored children, there isn't a need to include it in the snapshot since the children will return YES.
if (object.hasUnignoredChild())
return false;
// For leaf objects, include if they have a value (e.g., form controls).
if (!object.stringValue().isEmpty())
return true;
Vector<AccessibilityText> accessibilityText;
object.accessibilityText(accessibilityText);
#if PLATFORM(COCOA)
// For leaf objects, include if they have accessible description text (e.g., images with alt text).
if (!object.descriptionAttributeValue(&accessibilityText).isEmpty())
return true;
#endif
// Some leaf objects (like buttons) return their text via `title`.
if (!object.title(&accessibilityText).isEmpty())
return true;
return false;
}
// Returns the objects carrying text the region did not have before, which is empty when its text did
// not change at all. Streaming pages re-render their whole live region as each chunk arrives, which
// spreads already-announced text across a different set of objects. For example, a paragraph that splits
// into three once a bold span or citation resolves, and once the response is complete the many small nodes
// are coalesced into a few large ones. Comparing objects individually sees all of those as brand new and
// re-announces the whole response, so the region's combined text is what has to be compared.
//
// Returns std::nullopt when the text changed in some way other than growing at the end, or when either snapshot
// is too incomplete to compare.
static std::optional<Vector<LiveRegionObject>> appendedObjects(const LiveRegionSnapshot& oldSnapshot, const LiveRegionSnapshot& newSnapshot)
{
// A truncated snapshot's text can stop growing while the region keeps growing, which would read as
// "nothing changed" and silence the rest of the content. Fall back to comparing objects instead.
if (oldSnapshot.isTruncated || newSnapshot.isTruncated)
return std::nullopt;
// Atomic regions are announced whole, so they must not be reduced to their appended text.
if (oldSnapshot.hasAtomicRegion || newSnapshot.hasAtomicRegion)
return std::nullopt;
const String& oldAggregate = oldSnapshot.aggregatedText;
const String& newAggregate = newSnapshot.aggregatedText;
if (oldAggregate.isEmpty())
return std::nullopt;
// The same text spread across a different set of objects. Streaming pages coalesce their many
// small nodes into fewer, larger ones once the response is complete, which leaves the text
// unchanged but matches up with none of the previous objects. Nothing was added, so there is
// nothing to announce.
if (newAggregate == oldAggregate)
return Vector<LiveRegionObject> { };
if (!newAggregate.startsWith(oldAggregate))
return std::nullopt;
// Streaming often delivers partial words, so the point where the old text ended can fall in the
// middle of a word. Back up to the start of that word and announce it whole rather than announcing a
// fragment of it.
static constexpr size_t maximumWordBoundaryBackup = 32;
// A region whose entire text is this short is treated as a single value, such as a counter, where
// the new value should be announced in full rather than only the characters that changed.
static constexpr size_t maximumSingleValueLength = 8;
size_t splitOffset = oldAggregate.length();
if (!isUnicodeWhitespace(newAggregate[splitOffset])) {
size_t limit = splitOffset > maximumWordBoundaryBackup ? splitOffset - maximumWordBoundaryBackup : 0;
size_t candidate = splitOffset;
while (candidate > limit && !isUnicodeWhitespace(newAggregate[candidate - 1]))
--candidate;
if (candidate && isUnicodeWhitespace(newAggregate[candidate - 1]))
splitOffset = candidate;
else if (oldAggregate.length() <= maximumSingleValueLength) {
// No word boundary was found. Scripts that do not separate words with spaces (Chinese,
// Japanese, Thai) never have one, so only rewind the whole text when it is short enough to
// plausibly be a single value. Otherwise the entire region would be re-announced per chunk.
splitOffset = 0;
}
}
// Offsets were recorded alongside the text when the snapshot was built. They only cover every object
// when the walk ran to completion, which the truncation check above has already established.
const Vector<size_t>& objectEndOffsets = newSnapshot.objectEndOffsets;
ASSERT(objectEndOffsets.size() == newSnapshot.objects.size());
Vector<LiveRegionObject> appended;
size_t objectStart = 0;
for (size_t i = 0; i < newSnapshot.objects.size(); ++i) {
size_t objectEnd = objectEndOffsets[i];
size_t contributionStart = std::exchange(objectStart, objectEnd);
// Skip objects that contributed nothing, and those whose text was entirely announced already.
if (objectEnd <= contributionStart || objectEnd <= splitOffset)
continue;
LiveRegionObject object = newSnapshot.objects[i];
size_t announceFrom = contributionStart;
if (contributionStart < splitOffset) {
// Only text content can be cut mid-object. Anything else is a single accessible name or
// value (a button's title, an image's alt text), where announcing part of one would say
// something like "over" for a button relabelled "Start over", so those are announced whole.
if (object.isTextContent)
announceFrom = splitOffset;
}
// Take the text from the aggregate, which is already whitespace-collapsed.
object.text = newAggregate.substring(announceFrom, objectEnd - announceFrom).trim(isUnicodeWhitespace);
if (object.text.isEmpty() || isPunctuationOnly(object.text))
continue;
appended.append(WTF::move(object));
}
return appended;
}
AXLiveRegionManager::LiveRegionDiff AXLiveRegionManager::computeChanges(const LiveRegionSnapshot& oldSnapshot, const LiveRegionSnapshot& newSnapshot) const
{
// Here we compare the old and new live region to compute:
// - Additions: New objects, or atomic regions where nodes were added AND text changed.
// - Deletions: Objects that were removed from the region, or atomic regions where nodes were removed AND text changed.
// - Changes: Text content/values that changed between the same object (without node additions/removals).
const Vector<LiveRegionObject>& oldObjects = oldSnapshot.objects;
const Vector<LiveRegionObject>& newObjects = newSnapshot.objects;
LiveRegionDiff diff;
if (auto appended = appendedObjects(oldSnapshot, newSnapshot)) {
// The region's text only grew at the end, so announce just the new text. This has to be checked
// before comparing objects individually, because a re-render can spread the same text across a
// different set of objects, which object-level comparison sees as entirely new content.
// Text that grew on an object that was already there is a text change. Text on an object that
// was not there is an addition. aria-relevant distinguishes the two, so a region asking only for
// "additions" must not hear text edits, and one asking only for "text" must still hear them.
for (auto& object : *appended) {
bool objectExistedBefore = oldObjects.containsIf([&] (const auto& oldObject) {
return oldObject.objectID == object.objectID;
});
if (objectExistedBefore)
diff.changed.append(object);
else
diff.added.append(object);
}
return diff;
}
// Build a map of old objects for lookup. As we match them with new objects, we'll remove them.
// Whatever remains unmatched at the end represents removals.
HashMap<AXID, LiveRegionObjectMetadata> unmatchedOldObjects;
unmatchedOldObjects.reserveInitialCapacity(oldObjects.size());
// Index the old objects by text too, so content that survived a re-render can still be matched
// even though the objects backing it are new. See the second pass below.
HashMap<String, Vector<AXID>> oldObjectIDsByText;
for (auto& object : oldObjects) {
unmatchedOldObjects.set(object.objectID, LiveRegionObjectMetadata { object.text, object.language, object.descendants });
if (!object.text.isEmpty())
oldObjectIDsByText.add(object.text, Vector<AXID> { }).iterator->value.append(object.objectID);
}
// Additions are collected with the index of the object that produced them, so that the two passes
// below can be merged back into document order before anything is announced.
Vector<std::pair<size_t, LiveRegionObject>> additions;
// First pass. Try to match by AXID. This has to finish before any text matching happens below, so that
// objects which still exist are always paired with themselves rather than consumed by a text match.
Vector<size_t> unmatchedNewObjectIndices;
for (size_t i = 0; i < newObjects.size(); ++i) {
const auto& newObject = newObjects[i];
auto iterator = unmatchedOldObjects.find(newObject.objectID);
if (iterator == unmatchedOldObjects.end())
unmatchedNewObjectIndices.append(i);
else {
bool textChanged = iterator->value.text != newObject.text;
if (!newObject.descendants.isEmpty()) {
// This is an atomic region, indicated by the presence of children.
HashSet oldDescendantsCopy = iterator->value.descendants;
HashSet newDescendantsCopy = newObject.descendants;
newDescendantsCopy.removeAll(oldDescendantsCopy);
oldDescendantsCopy.removeAll(newObject.descendants);
bool nodesAdded = newDescendantsCopy.size();
bool nodesRemoved = oldDescendantsCopy.size();
if (nodesAdded && textChanged)
additions.append({ i, newObject });
else if (nodesRemoved && textChanged)
diff.removed.append(newObject);
if (textChanged)
diff.changed.append(newObject);
} else if (textChanged)
diff.changed.append(newObject);
unmatchedOldObjects.remove(iterator);
}
}
// Second pass. For new objects that had no AXID match, try to match them to a leftover old object with
// identical text. Re-rendering a region destroys and recreates the objects holding text that was
// already announced, so matching only by AXID makes that text look new and announces it again.
for (auto index : unmatchedNewObjectIndices) {
const auto& newObject = newObjects[index];
bool matchedIdenticalText = false;
// The text is only looked up when it is non-empty, matching how oldObjectIDsByText was built.
if (!newObject.text.isEmpty()) {
if (auto iterator = oldObjectIDsByText.find(newObject.text); iterator != oldObjectIDsByText.end()) {
// An old object's text can only be claimed once, so drop candidates already matched by AXID above.
while (!iterator->value.isEmpty()) {
if (unmatchedOldObjects.remove(iterator->value.takeLast())) {
matchedIdenticalText = true;
break;
}
}
}
}
// Text carried over unchanged from the previous snapshot has nothing to announce.
if (!matchedIdenticalText)
additions.append({ index, newObject });
}
// Merge the two passes back into document order. Announcing a new object before a changed atomic
// region that precedes it would reverse the reading order of the announcement.
std::sort(additions.begin(), additions.end(), [] (const auto& a, const auto& b) {
return a.first < b.first;
});
diff.added.reserveInitialCapacity(additions.size());
for (auto& addition : additions)
diff.added.append(WTF::move(addition.second));
// Anything left unmatched is a removal. Walk the old objects rather than the map so that removals
// come out in document order rather than hash order.
for (auto& oldObject : oldObjects) {
if (unmatchedOldObjects.contains(oldObject.objectID))
diff.removed.append({ oldObject.objectID, oldObject.text, oldObject.language, { } });
}
return diff;
}
static const size_t maximumAnnouncementLength = 2500;
enum class IsLiveRegionRemoval : bool { No, Yes };
AttributedString AXLiveRegionManager::computeAnnouncement(OptionSet<LiveRegionRelevant> liveRegionRelevant, const LiveRegionDiff& diff) const
{
bool hasAll = liveRegionRelevant.contains(LiveRegionRelevant::All);
bool hasAdditions = hasAll || liveRegionRelevant.contains(LiveRegionRelevant::Additions);
bool hasRemovals = hasAll || liveRegionRelevant.contains(LiveRegionRelevant::Removals);
bool hasText = hasAll || liveRegionRelevant.contains(LiveRegionRelevant::Text);
StringBuilder stringBuilder;
Vector<std::pair<AttributedString::Range, HashMap<String, AttributedString::AttributeValue>>> attributes;
size_t characterCount = 0;
HashSet<AXID> spokenObjects = { };
// Determines whether we should add a space before adding the next object. Should only be false the first call.
bool needsSpace = false;
auto appendStringAndLanguage = [&](const LiveRegionObject& object, IsLiveRegionRemoval isRemoval = IsLiveRegionRemoval::No) {
if (object.text.isEmpty() || spokenObjects.contains(object.objectID))
return;
if (needsSpace) {
stringBuilder.append(' ');
characterCount++;
}
uint64_t startLocation = stringBuilder.length();
stringBuilder.append(object.text);
characterCount += object.text.length();
if (!object.language.isEmpty()) {
HashMap<String, AttributedString::AttributeValue> languageAttribute;
languageAttribute.set(accessibilityLanguageAttributeKey, AttributedString::AttributeValue { object.language });
// The - / + 1 allows us to set the language of the space character seemlessly with the text around it.
attributes.append({ { needsSpace && startLocation ? startLocation - 1 : startLocation, needsSpace && startLocation ? object.text.length() + 1 : object.text.length() }, WTF::move(languageAttribute) });
}
if (isRemoval == IsLiveRegionRemoval::Yes) {
HashMap<String, AttributedString::AttributeValue> removalAttribute;
removalAttribute.set(accessibilityIsLiveRegionRemovalAttributeKey, AttributedString::AttributeValue { 1.0 });
attributes.append({ { needsSpace && startLocation ? startLocation - 1 : startLocation, needsSpace && startLocation ? object.text.length() + 1 : object.text.length() }, WTF::move(removalAttribute) });
}
// If the preceeding object already ends with a space (e.g., list markers), no need to add another.
needsSpace = object.text.isEmpty() || object.text[object.text.length() - 1] != ' ';
spokenObjects.add(object.objectID);
};
auto announceObjects = [&](const Vector<LiveRegionObject>& objects, IsLiveRegionRemoval isRemoval = IsLiveRegionRemoval::No, bool textContentOnly = false) {
for (auto& object : objects) {
if (characterCount > maximumAnnouncementLength)
break;
if (textContentOnly && !object.isTextContent)
continue;
appendStringAndLanguage(object, isRemoval);
}
};
// "additions" announces all new nodes. "text" without "additions" announces only added text
// nodes (e.g. textContent/innerText replacement creates new text nodes, not element additions).
// When both are set, "additions" already covers text nodes so the text-only path is skipped.
if (hasAdditions)
announceObjects(diff.added);
else if (hasText)
announceObjects(diff.added, IsLiveRegionRemoval::No, /* textContentOnly */ true);
if (hasRemovals)
announceObjects(diff.removed, IsLiveRegionRemoval::Yes);
if (hasText)
announceObjects(diff.changed);
auto string = stringBuilder.toString();
return AttributedString { WTF::move(string), WTF::move(attributes), std::nullopt };
}
void AXLiveRegionManager::postAnnouncementForChange(AccessibilityObject& object, const LiveRegionSnapshot& oldSnapshot, const LiveRegionSnapshot& newSnapshot)
{
auto diff = computeChanges(oldSnapshot, newSnapshot);
if (diff.added.isEmpty() && diff.removed.isEmpty() && diff.changed.isEmpty())
return;
// Collect the text of every object that could contribute to the announcement, in the order
// computeAnnouncement() will consider them, so translated text can be written back positionally.
Vector<String> segments;
auto collect = [&](const Vector<LiveRegionObject>& objects) {
for (auto& liveRegionObject : objects)
segments.append(liveRegionObject.text);
};
collect(diff.added);
collect(diff.removed);
collect(diff.changed);
// Translation has to happen before computeAnnouncement(), not after, because it concatenates these
// objects into one AttributedString with per-range language and removal attributes, and word
// order changes across languages would make those ranges unrecoverable. Assembling afterwards
// also applies maximumAnnouncementLength to the translated text, which matters because
// translations commonly run longer than their source.
auto expectedSegmentCount = segments.size();
// Capture only the two values the announcement needs. Capturing the snapshot would deep copy every
// object in the region on every update just to read them.
auto liveRegionStatus = newSnapshot.liveRegionStatus;
auto liveRegionRelevant = newSnapshot.liveRegionRelevant;
auto assemble = [protectedThis = CheckedRef { *this }, object = Ref { object }, diff, liveRegionStatus, liveRegionRelevant, expectedSegmentCount](Vector<String>&& translatedSegments, const String& language) mutable {
if (translatedSegments.size() == expectedSegmentCount) {
size_t index = 0;
auto applyTranslation = [&](Vector<LiveRegionObject>& objects) {
for (auto& liveRegionObject : objects) {
liveRegionObject.text = WTF::move(translatedSegments[index++]);
if (!language.isEmpty())
liveRegionObject.language = language;
}
};
applyTranslation(diff.added);
applyTranslation(diff.removed);
applyTranslation(diff.changed);
}
AttributedString announcement = protectedThis->computeAnnouncement(liveRegionRelevant, diff);
if (announcement.isNull() || announcement.string.isEmpty())
return;
CheckedRef { protectedThis->m_cache }->postLiveRegionNotification(object, liveRegionStatus, announcement);
};
CheckedRef { m_cache }->translateAnnouncementThenAssemble(Ref { object }, WTF::move(segments), WTF::move(assemble));
}
} // namespace WebCore
#endif // PLATFORM(COCOA)