blob: 1096f0146bbe93f51c9734296f476af20c1ee91b [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.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. 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 INC. 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 "AXTableHelpers.h"
#include "AXCoreObject.h"
#include "AXObjectCache.h"
#include "AXUtilities.h"
#include "ContainerNodeInlines.h"
#include "Color.h"
#include "ElementAncestorIteratorInlines.h"
#include "ElementChildIteratorInlines.h"
#include "HTMLTableCaptionElement.h"
#include "HTMLTableCellElement.h"
#include "HTMLTableElement.h"
#include "HTMLTableRowElement.h"
#include "HTMLTableSectionElement.h"
#include "NodeRenderStyle.h"
#include "RenderElementInlines.h"
#include "RenderObject.h"
#include "RenderStyle.h"
#include "RenderTable.h"
#include "RenderTableCell.h"
#include "RenderTableRow.h"
#include "StylePrimitiveNumericTypes+Evaluation.h"
#include <queue>
namespace WebCore {
namespace AXTableHelpers {
using namespace HTMLNames;
bool appendCaptionTextIfNecessary(Element& element, Vector<AccessibilityText>& textOrder)
{
if (RefPtr tableElement = dynamicDowncast<HTMLTableElement>(element)) {
RefPtr caption = tableElement->caption();
if (String captionText = caption ? caption->innerText() : emptyString(); !captionText.isEmpty()) {
textOrder.append(AccessibilityText(WTF::move(captionText), AccessibilityTextSource::LabelByElement));
return true;
}
}
return false;
}
bool isTableRole(AccessibilityRole role)
{
switch (role) {
case AccessibilityRole::Table:
case AccessibilityRole::Grid:
case AccessibilityRole::TreeGrid:
return true;
default:
return false;
}
}
bool hasRowRole(Element& element)
{
return hasRole(element, "row"_s);
}
bool isTableRowElement(Element& element)
{
if (hasRowRole(element))
return true;
if (!hasRole(element, nullAtom())) {
// This has a non-row role, so it shouldn't be considered a row.
return false;
}
bool isAnonymous = false;
CheckedPtr renderer = element.renderer();
#if USE(ATSPI)
isAnonymous = renderer && renderer->isAnonymous();
#endif
if (is<RenderTableRow>(renderer.get()) && !isAnonymous)
return true;
return is<HTMLTableRowElement>(element);
}
bool isTableCellElement(Element& element)
{
if (hasCellARIARole(element))
return true;
if (is<HTMLTableCellElement>(element) && hasRole(element, nullAtom()))
return true;
bool isAnonymous = false;
CheckedPtr renderer = element.renderer();
#if USE(ATSPI)
isAnonymous = renderer && renderer->isAnonymous();
#endif
return is<RenderTableCell>(renderer) && !isAnonymous;
}
HTMLTableElement* tableElementIncludingAncestors(Node* node, RenderObject* renderer)
{
if (auto* tableElement = dynamicDowncast<HTMLTableElement>(node))
return tableElement;
auto* renderTable = dynamicDowncast<RenderTable>(renderer);
if (!renderTable)
return nullptr;
if (auto* tableElement = dynamicDowncast<HTMLTableElement>(renderTable->element()))
return tableElement;
// Try to find the table element when the object is mapped to an anonymous table renderer.
CheckedPtr firstChild = renderTable->firstChild();
if (!firstChild || !firstChild->node())
return nullptr;
if (auto* childTable = dynamicDowncast<HTMLTableElement>(firstChild->node()))
return childTable;
// FIXME: This might find an unrelated parent table element.
return ancestorsOfType<HTMLTableElement>(*(firstChild->node())).first();
}
bool tableElementIndicatesAccessibleTable(HTMLTableElement& tableElement)
{
// If there is a caption element, summary, THEAD, or TFOOT section, it's most certainly a data table.
if (!tableElement.summary().isEmpty()
|| (tableElement.tHead() && tableElement.tHead()->renderer())
|| (tableElement.tFoot() && tableElement.tFoot()->renderer())
|| tableElement.caption())
return true;
// If someone used "rules" attribute than the table should appear.
if (!tableElement.rules().isEmpty())
return true;
// If there's a colgroup or col element, it's probably a data table.
for (const Ref child : childrenOfType<HTMLElement>(tableElement)) {
auto elementName = child->elementName();
if (elementName == ElementName::HTML_col || elementName == ElementName::HTML_colgroup)
return true;
}
return false;
}
bool tableSectionIndicatesAccessibleTable(HTMLTableSectionElement& sectionElement, AXObjectCache& cache)
{
// Use the presence of any non-group role as a sign that the author wants this to be an accessibility table (rather
// than a layout table).
if (RefPtr axTableSection = cache.getOrCreate(sectionElement)) {
auto role = axTableSection->role();
if (!axTableSection->isGroup() && role != AccessibilityRole::Unknown && role != AccessibilityRole::Ignored)
return true;
}
return false;
}
static const RenderStyle* styleFrom(Element& element)
{
if (auto* renderStyle = element.renderStyle())
return renderStyle;
return element.existingComputedStyle();
}
bool isDataTableWithTraversal(HTMLTableElement& tableElement, AXObjectCache& cache)
{
bool didTopSectionCheck = false;
auto topSectionIndicatesLayoutTable = [&] (HTMLTableSectionElement* tableSectionElement) {
if (didTopSectionCheck || !tableSectionElement)
return false;
didTopSectionCheck = true;
return tableSectionIndicatesAccessibleTable(*tableSectionElement, cache);
};
// Store the background color of the table to check against cell's background colors.
Color tableBackgroundColor = Color::white;
unsigned tableHorizontalBorderSpacing = 0;
unsigned tableVerticalBorderSpacing = 0;
if (CheckedPtr<const RenderStyle> tableStyle = safeStyleFrom(tableElement)) {
tableBackgroundColor = tableStyle->visitedDependentBackgroundColor();
tableHorizontalBorderSpacing = tableStyle->borderHorizontalSpacing().resolveZoom(tableStyle->usedZoomForLength());
tableVerticalBorderSpacing = tableStyle->borderVerticalSpacing().resolveZoom(tableStyle->usedZoomForLength());
}
unsigned cellCount = 0;
unsigned borderedCellCount = 0;
unsigned backgroundDifferenceCellCount = 0;
unsigned cellsWithTopBorder = 0;
unsigned cellsWithBottomBorder = 0;
unsigned cellsWithLeftBorder = 0;
unsigned cellsWithRightBorder = 0;
HashMap<Node*, unsigned> cellCountForEachRow;
std::array<Color, 5> alternatingRowColors;
int alternatingRowColorCount = 0;
unsigned rowCount = 0;
unsigned maxColumnCount = 0;
auto isDataTableBasedOnRowColumnCount = [&] () {
// If there are at least 20 rows, we'll call it a data table.
return (rowCount >= 20 && maxColumnCount >= 2) || (rowCount >= 2 && maxColumnCount >= 20);
};
bool firstColumnHasAllHeaderCells = true;
RefPtr<HTMLTableRowElement> firstRow;
RefPtr<HTMLTableSectionElement> firstBody;
RefPtr<HTMLTableSectionElement> firstFoot;
// Do a breadth-first search to determine if this is a data table.
std::queue<RefPtr<Element>> elementsToVisit;
elementsToVisit.push(tableElement);
while (!elementsToVisit.empty()) {
RefPtr currentParent = elementsToVisit.front();
elementsToVisit.pop();
bool rowIsAllTableHeaderCells = true;
for (RefPtr currentElement = currentParent ? currentParent->firstElementChild() : nullptr; currentElement; currentElement = currentElement->nextElementSibling()) {
if (auto* tableSectionElement = dynamicDowncast<HTMLTableSectionElement>(currentElement.get())) {
auto elementName = tableSectionElement->elementName();
if (elementName == ElementName::HTML_thead) {
if (topSectionIndicatesLayoutTable(tableSectionElement))
return false;
} else if (elementName == ElementName::HTML_tbody)
firstBody = firstBody ? firstBody : RefPtr { tableSectionElement };
else {
ASSERT_WITH_MESSAGE(elementName == ElementName::HTML_tfoot, "table section elements should always have either thead, tbody, or tfoot tag");
firstFoot = firstFoot ? firstFoot : RefPtr { tableSectionElement };
}
} else if (auto* tableRow = dynamicDowncast<HTMLTableRowElement>(currentElement.get())) {
firstRow = firstRow ? firstRow : RefPtr { tableRow };
rowCount += 1;
if (isDataTableBasedOnRowColumnCount())
return true;
if (tableRow->integralAttribute(aria_rowindexAttr) >= 1 || tableRow->integralAttribute(aria_colindexAttr) || !tableRow->getAttribute(aria_rowindextextAttr).isEmpty() || hasRole(*tableRow, "row"_s))
return true;
// For the first 5 rows, cache the background color so we can check if this table has zebra-striped rows.
if (alternatingRowColorCount < 5) {
if (CheckedPtr<const RenderStyle> rowStyle = styleFrom(*tableRow)) {
alternatingRowColors[alternatingRowColorCount] = rowStyle->visitedDependentBackgroundColor();
alternatingRowColorCount++;
}
}
} else if (auto* cell = dynamicDowncast<HTMLTableCellElement>(currentElement.get())) {
cellCount++;
bool isTHCell = cell->elementName() == ElementName::HTML_th;
if (!isTHCell && rowIsAllTableHeaderCells)
rowIsAllTableHeaderCells = false;
if (RefPtr parentNode = cell->parentNode()) {
auto cellCountForRowIterator = cellCountForEachRow.ensure(parentNode.get(), [&] {
// If we don't have an entry for this parent yet, it must be the first column.
if (!isTHCell && firstColumnHasAllHeaderCells)
firstColumnHasAllHeaderCells = false;
return 0;
}).iterator;
cellCountForRowIterator->value += 1;
maxColumnCount = std::max(cellCountForRowIterator->value, maxColumnCount);
if (isDataTableBasedOnRowColumnCount())
return true;
}
// In this case, the developer explicitly assigned a "data" table attribute.
if (!cell->headers().isEmpty() || !cell->abbr().isEmpty() || !cell->axis().isEmpty() || !cell->scope().isEmpty() || hasCellARIARole(*cell))
return true;
// If the author has used ARIA to specify a valid column or row index or index text, assume they want us
// to treat the table as a data table.
if (cell->integralAttribute(aria_colindexAttr) >= 1 || cell->integralAttribute(aria_rowindexAttr) >= 1 || !cell->getAttribute(aria_colindextextAttr).isEmpty() || !cell->getAttribute(aria_rowindextextAttr).isEmpty())
return true;
// If the author has used ARIA to specify a column or row span, we're supposed to ignore
// the value for the purposes of exposing the span. But assume they want us to treat the
// table as a data table.
if (cell->integralAttribute(aria_colspanAttr) >= 1 || cell->integralAttribute(aria_rowspanAttr) >= 1)
return true;
Color cellColor = Color::white;
if (CheckedPtr<const RenderStyle> cellStyle = styleFrom(*cell)) {
if (cellStyle->emptyCells() == EmptyCell::Hide) {
// If the empty-cells style is set, we'll call it a data table.
return true;
}
cellColor = cellStyle->visitedDependentBackgroundColor();
}
if (CheckedPtr cellRenderer = dynamicDowncast<RenderBlock>(cell->renderer())) {
bool hasBorderTop = cellRenderer->borderTop() > 0;
bool hasBorderBottom = cellRenderer->borderBottom() > 0;
bool hasBorderLeft = cellRenderer->borderLeft() > 0;
bool hasBorderRight = cellRenderer->borderRight() > 0;
// If a cell has matching bordered sides, call it a (fully) bordered cell.
if ((hasBorderTop && hasBorderBottom) || (hasBorderLeft && hasBorderRight))
borderedCellCount++;
// Also keep track of each individual border, so we can catch tables where most
// cells have a bottom border, for example.
if (hasBorderTop)
cellsWithTopBorder++;
if (hasBorderBottom)
cellsWithBottomBorder++;
if (hasBorderLeft)
cellsWithLeftBorder++;
if (hasBorderRight)
cellsWithRightBorder++;
}
// If the cell has a different color from the table and there is cell spacing,
// then it is probably a data table cell (spacing and colors take the place of borders).
if (tableHorizontalBorderSpacing > 0 && tableVerticalBorderSpacing > 0 && tableBackgroundColor != cellColor && !cellColor.isOpaque())
backgroundDifferenceCellCount++;
// If we've found 10 "good" cells, we don't need to keep searching.
if (borderedCellCount >= 10 || backgroundDifferenceCellCount >= 10)
return true;
} else if (is<HTMLTableElement>(currentElement)) {
// Do not descend into nested tables. (Implemented by continuing before pushing the current element into the BFS elementsToVisit queue)
continue;
}
elementsToVisit.push(currentElement);
}
// If the first row of a multi-row table is comprised of all <th> tags, assume it is a data table.
if (firstRow && currentParent == firstRow && rowIsAllTableHeaderCells && cellCountForEachRow.get(currentParent.get()) >= 1 && rowCount >= 2)
return true;
}
// If there is less than two valid cells, it's not a data table.
if (cellCount <= 1)
return false;
if (topSectionIndicatesLayoutTable(firstBody.get()) || topSectionIndicatesLayoutTable(firstFoot.get()))
return false;
if (firstColumnHasAllHeaderCells && rowCount >= 2)
return true;
// At least half of the cells had borders, it's a data table.
unsigned neededCellCount = cellCount / 2;
if (borderedCellCount >= neededCellCount
|| cellsWithTopBorder >= neededCellCount
|| cellsWithBottomBorder >= neededCellCount
|| cellsWithLeftBorder >= neededCellCount
|| cellsWithRightBorder >= neededCellCount)
return true;
// At least half of the cells had different background colors, it's a data table.
if (backgroundDifferenceCellCount >= neededCellCount)
return true;
if (isDataTableBasedOnRowColumnCount())
return true;
// Check if there is an alternating row background color indicating a zebra striped style pattern.
if (alternatingRowColorCount > 2) {
Color firstColor = alternatingRowColors[0];
for (int k = 1; k < alternatingRowColorCount; k++) {
// If an odd row was the same color as the first row, it's not alternating.
if (k % 2 == 1 && alternatingRowColors[k] == firstColor)
return false;
// If an even row is not the same as the first row, it's not alternating.
if (!(k % 2) && alternatingRowColors[k] != firstColor)
return false;
}
return true;
}
return false;
}
} // namespace AXTableHelpers
} // namespace WebCore