blob: 49a753be1c4deaf3bf38743a9c49f183f32f931b [file] [log] [blame] [edit]
// 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 "third_party/blink/renderer/modules/content_extraction/ai_page_content_agent.h"
#include "base/containers/adapters.h"
#include "base/time/time.h"
#include "base/trace_event/trace_event.h"
#include "base/trace_event/trace_id_helper.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/mojom/content_extraction/ai_page_content.mojom-blink.h"
#include "third_party/blink/public/mojom/content_extraction/ai_page_content.mojom-forward.h"
#include "third_party/blink/renderer/core/accessibility/ax_object_cache.h"
#include "third_party/blink/renderer/core/css/properties/longhands.h"
#include "third_party/blink/renderer/core/display_lock/display_lock_document_state.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/dom/dom_node_ids.h"
#include "third_party/blink/renderer/core/editing/editing_utilities.h"
#include "third_party/blink/renderer/core/editing/frame_selection.h"
#include "third_party/blink/renderer/core/editing/selection_template.h"
#include "third_party/blink/renderer/core/exported/web_view_impl.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/local_frame_view.h"
#include "third_party/blink/renderer/core/html/forms/html_form_control_element.h"
#include "third_party/blink/renderer/core/html/forms/html_form_element.h"
#include "third_party/blink/renderer/core/html/forms/html_input_element.h"
#include "third_party/blink/renderer/core/html/forms/html_label_element.h"
#include "third_party/blink/renderer/core/html/forms/html_option_element.h"
#include "third_party/blink/renderer/core/html/forms/html_select_element.h"
#include "third_party/blink/renderer/core/html/forms/option_list.h"
#include "third_party/blink/renderer/core/html/forms/text_control_element.h"
#include "third_party/blink/renderer/core/html/html_anchor_element.h"
#include "third_party/blink/renderer/core/html/html_head_element.h"
#include "third_party/blink/renderer/core/html/html_image_element.h"
#include "third_party/blink/renderer/core/html/html_meta_element.h"
#include "third_party/blink/renderer/core/html/media/html_video_element.h"
#include "third_party/blink/renderer/core/input/event_handler.h"
#include "third_party/blink/renderer/core/layout/layout_embedded_content.h"
#include "third_party/blink/renderer/core/layout/layout_html_canvas.h"
#include "third_party/blink/renderer/core/layout/layout_iframe.h"
#include "third_party/blink/renderer/core/layout/layout_image.h"
#include "third_party/blink/renderer/core/layout/layout_media.h"
#include "third_party/blink/renderer/core/layout/layout_object.h"
#include "third_party/blink/renderer/core/layout/layout_text_fragment.h"
#include "third_party/blink/renderer/core/layout/layout_video.h"
#include "third_party/blink/renderer/core/layout/layout_view.h"
#include "third_party/blink/renderer/core/layout/map_coordinates_flags.h"
#include "third_party/blink/renderer/core/layout/svg/layout_svg_root.h"
#include "third_party/blink/renderer/core/layout/table/layout_table.h"
#include "third_party/blink/renderer/core/layout/table/layout_table_caption.h"
#include "third_party/blink/renderer/core/layout/table/layout_table_row.h"
#include "third_party/blink/renderer/core/layout/table/layout_table_section.h"
#include "third_party/blink/renderer/core/page/chrome_client.h"
#include "third_party/blink/renderer/core/page/page.h"
#include "third_party/blink/renderer/core/paint/clip_path_clipper.h"
#include "third_party/blink/renderer/core/script_tools/model_context_supplement.h"
#include "third_party/blink/renderer/core/style/computed_style.h"
#include "third_party/blink/renderer/modules/accessibility/ax_object.h"
#include "third_party/blink/renderer/modules/content_extraction/ai_page_content_debug_utils.h"
#include "third_party/blink/renderer/platform/runtime_enabled_features.h"
#include "third_party/blink/renderer/platform/scheduler/public/thread_scheduler.h"
#include "third_party/blink/renderer/platform/weborigin/security_origin.h"
#include "third_party/blink/renderer/platform/widget/frame_widget.h"
#include "ui/accessibility/ax_role_properties.h"
#include "ui/gfx/geometry/point_conversions.h"
#include "ui/gfx/geometry/rect_conversions.h"
namespace blink {
namespace {
// Coordinate mapping flags
// - Viewport mapping: positions relative to the window/viewport origin.
constexpr MapCoordinatesFlags kMapToViewportFlags =
kTraverseDocumentBoundaries | kApplyRemoteViewportTransform;
constexpr VisualRectFlags kVisualRectFlags = static_cast<VisualRectFlags>(
kUseGeometryMapper | kVisualRectApplyRemoteViewportTransform |
kIgnoreFilters);
constexpr float kHeading1FontSizeMultiplier = 2;
constexpr float kHeading3FontSizeMultiplier = 1.17;
constexpr float kHeading5FontSizeMultiplier = 0.83;
constexpr float kHeading6FontSizeMultiplier = 0.67;
ListBasedHitTestBehavior CollectHitTestNodes(std::vector<DOMNodeId>& hit_nodes,
const Node& node,
DOMNodeId dom_node_id) {
if (node.GetLayoutObject()) {
hit_nodes.push_back(dom_node_id);
}
return kContinueHitTesting;
}
// Computes the visible portion of a LayoutObject's bounding box.
//
// This function calculates what part of the object is actually visible in the
// viewport, taking into account:
// - The object's local bounding box (its natural size and position)
// - Viewport clipping (objects outside the viewport are clipped)
// - Scroll offsets (objects scrolled out of view are clipped)
// - CSS overflow clipping from ancestor containers
//
// The returned rectangle is in viewport coordinates (relative to the top-left
// of the visible area), which is why coordinates are always >= 0.
gfx::Rect ComputeVisibleBoundingBox(const LayoutObject& object) {
// Layout must be complete before computing bounding boxes.
DCHECK(object.GetDocument().Lifecycle().GetState() >=
DocumentLifecycle::kLayoutClean)
<< "ComputeVisibleBoundingBox only works when layout is complete";
// Get the object's local bounding box before viewport clipping.
gfx::RectF object_rect =
ClipPathClipper::LocalClipPathBoundingBox(object).value_or(
object.LocalBoundingBoxRectForAccessibility(
LayoutObject::IncludeDescendants(false)));
// Transform the local bounding box to viewport coordinates, applying:
// 1. All CSS transforms (translate, scale, rotate, etc.)
// 2. Scroll offsets from all ancestor scroll containers
// 3. Clipping from overflow:hidden containers
// 4. Viewport clipping (anything outside the viewport is clipped)
//
// The nullptr ancestor means "map to the root of the document". When used
// with kVisualRectFlags, this gives us viewport-relative coordinates.
// TODO(khushalsagar): It might be more optimal to derive this from output of
// paint.
object.MapToVisualRectInAncestorSpace(nullptr, object_rect, kVisualRectFlags);
gfx::Rect visible_box_in_viewport_coords = ToEnclosingRect(object_rect);
#if DCHECK_IS_ON() && !defined(OFFICIAL_BUILD)
// The visible bounding box should always have non-negative coordinates since
// it's relative to the viewport. Negative coordinates would indicate a bug
// in the coordinate transformation.
DCHECK_GE(visible_box_in_viewport_coords.x(), 0)
<< "Visible bounding box should be viewport-relative with x >= 0, got: "
<< visible_box_in_viewport_coords.ToString() << " for object: " << object;
DCHECK_GE(visible_box_in_viewport_coords.y(), 0)
<< "Visible bounding box should be viewport-relative with y >= 0, got: "
<< visible_box_in_viewport_coords.ToString() << " for object: " << object;
#endif
return visible_box_in_viewport_coords;
}
gfx::Rect ComputeOuterBoundingBox(const LayoutObject& object) {
const std::optional<gfx::RectF> clip_path_box =
ClipPathClipper::LocalClipPathBoundingBox(object);
if (clip_path_box.has_value()) {
gfx::QuadF absolute_quad = object.LocalToAbsoluteQuad(
gfx::QuadF(clip_path_box.value()), kMapToViewportFlags);
return gfx::ToEnclosingRect(absolute_quad.BoundingBox());
}
gfx::Rect absolute_box = object.AbsoluteBoundingBoxRect(kMapToViewportFlags);
// Normalize empty boxes to make test results easier to read.
return absolute_box.IsEmpty() ? gfx::Rect() : absolute_box;
}
// Processes fragment bounding boxes for layout objects that can be split.
//
// Uses QuadsInAncestor() to retrieve quads for each object, then converts them
// to integer bounding rects.
//
// In CSS layout, some objects can be "fragmented" - split across multiple
// visual areas. This includes:
// - Text that wraps across multiple lines
// - Content that flows across CSS columns
//
// Each fragment represents a visual piece of the same logical object.
// We only store fragment boxes when there are multiple fragments (size > 1),
// as single fragments are redundant with the main bounding box.
void ComputeFragmentBoundingBoxes(
const LayoutObject& object,
mojom::blink::AIPageContentGeometry& geometry) {
Vector<gfx::QuadF> fragment_quads_in_viewport_coords;
object.QuadsInAncestor(fragment_quads_in_viewport_coords,
/*ancestor=*/nullptr, kMapToViewportFlags);
Vector<gfx::Rect> fragment_rects_in_viewport_coords;
for (const auto& fragment_quad_in_viewport_coords :
fragment_quads_in_viewport_coords) {
gfx::Rect fragment_enclosing_rect_in_viewport_coords =
gfx::ToEnclosingRect(fragment_quad_in_viewport_coords.BoundingBox());
// Clip to the viewport by intersecting with the element's visible bounding
// box (viewport-relative).
fragment_enclosing_rect_in_viewport_coords.Intersect(
geometry.visible_bounding_box);
if (!fragment_enclosing_rect_in_viewport_coords.IsEmpty()) {
fragment_rects_in_viewport_coords.push_back(
fragment_enclosing_rect_in_viewport_coords);
}
}
if (fragment_rects_in_viewport_coords.size() > 1) {
geometry.fragment_visible_bounding_boxes =
std::move(fragment_rects_in_viewport_coords);
}
}
// Validates the relationship between outer and visible bounding boxes.
//
// The visible bounding box should generally be contained within or equal to
// the outer bounding box, since it represents the visible portion of the
// object. However, there are some exceptions:
// 1. Inline elements can have different calculation methods that cause slight
// differences
// 2. Floating-point to integer conversions can introduce small rounding errors
// 3. CSS transforms can cause complex geometric relationships
#if DCHECK_IS_ON() && !defined(OFFICIAL_BUILD)
void ValidateBoundingBoxes(const gfx::Rect& outer_box_in_absolute_coords,
const gfx::Rect& visible_box_in_viewport_coords,
const LayoutObject& object) {
// Visible box coordinates should always be viewport-relative (>= 0)
DCHECK_GE(visible_box_in_viewport_coords.x(), 0)
<< "Visible box should have x >= 0, got: "
<< visible_box_in_viewport_coords.ToString() << " for object: " << object;
DCHECK_GE(visible_box_in_viewport_coords.y(), 0)
<< "Visible box should have y >= 0, got: "
<< visible_box_in_viewport_coords.ToString() << " for object: " << object;
// For block-level elements, the visible box should generally be no larger
// than the outer box (with some tolerance for rounding errors).
// Inline elements are exempt because they can have different calculation
// methods that cause the visible box to be larger.
// TODO(crbug.com/422588784): Fixinline element box sizing and enable check.
if (!object.IsInline()) {
const int kTolerancePixels = 1;
DCHECK_LE(visible_box_in_viewport_coords.width(),
outer_box_in_absolute_coords.width() + kTolerancePixels)
<< "Visible box width should not exceed outer box width by more than "
<< kTolerancePixels
<< "px. Visible: " << visible_box_in_viewport_coords.ToString()
<< ", Outer: " << outer_box_in_absolute_coords.ToString()
<< " for object: " << object;
DCHECK_LE(visible_box_in_viewport_coords.height(),
outer_box_in_absolute_coords.height() + kTolerancePixels)
<< "Visible box height should not exceed outer box height by more than "
<< kTolerancePixels
<< "px. Visible: " << visible_box_in_viewport_coords.ToString()
<< ", Outer: " << outer_box_in_absolute_coords.ToString()
<< " for object: " << object;
}
}
#endif // DCHECK_IS_ON()
void ComputeScrollerInfo(
const LayoutObject& object,
mojom::blink::AIPageContentNodeInteractionInfo& interaction_info) {
if (!object.IsBoxModelObject()) {
return;
}
auto* scrollable_area = To<LayoutBoxModelObject>(object).GetScrollableArea();
if (!scrollable_area) {
return;
}
const auto scrolling_bounds = scrollable_area->ContentsSize();
const auto visible_area = scrollable_area->VisibleContentRect();
// If the visible area covers the scrollable area, scrolling this node will be
// a no-op. Allow 1px of slop due to differences in rounding.
constexpr int kTolerance = 1;
if (scrolling_bounds.width() - visible_area.width() < kTolerance &&
scrolling_bounds.height() - visible_area.height() < kTolerance) {
return;
}
auto scroller_info = mojom::blink::AIPageContentScrollerInfo::New();
scroller_info->scrolling_bounds = scrolling_bounds;
scroller_info->visible_area = visible_area;
scroller_info->user_scrollable_horizontal =
scrollable_area->UserInputScrollable(kHorizontalScrollbar);
scroller_info->user_scrollable_vertical =
scrollable_area->UserInputScrollable(kVerticalScrollbar);
interaction_info.scroller_info = std::move(scroller_info);
}
// TODO(crbug.com/383128653): This is duplicating logic from
// UnsupportedTagTypeValueForNode, consider reusing it.
bool IsHeadingTag(const HTMLElement& element) {
return element.HasTagName(html_names::kH1Tag) ||
element.HasTagName(html_names::kH2Tag) ||
element.HasTagName(html_names::kH3Tag) ||
element.HasTagName(html_names::kH4Tag) ||
element.HasTagName(html_names::kH5Tag) ||
element.HasTagName(html_names::kH6Tag);
}
mojom::blink::AIPageContentAnchorRel GetAnchorRel(const AtomicString& rel) {
if (rel == "noopener") {
return mojom::blink::AIPageContentAnchorRel::kRelationNoOpener;
} else if (rel == "noreferrer") {
return mojom::blink::AIPageContentAnchorRel::kRelationNoReferrer;
} else if (rel == "opener") {
return mojom::blink::AIPageContentAnchorRel::kRelationOpener;
} else if (rel == "privacy-policy") {
return mojom::blink::AIPageContentAnchorRel::kRelationPrivacyPolicy;
} else if (rel == "terms-of-service") {
return mojom::blink::AIPageContentAnchorRel::kRelationTermsOfService;
}
return mojom::blink::AIPageContentAnchorRel::kRelationUnknown;
}
// Returns the relative text size of the object compared to the document
// default. Ratios are based on browser defaults for headings, which are as
// follows:
//
// Heading 1: 2em
// Heading 2: 1.5em
// Heading 3: 1.17em
// Heading 4: 1em
// Heading 5: 0.83em
// Heading 6: 0.67em
mojom::blink::AIPageContentTextSize GetTextSize(
const ComputedStyle& style,
const ComputedStyle& document_style) {
float font_size_multiplier =
style.ComputedFontSize() / document_style.ComputedFontSize();
if (font_size_multiplier >= kHeading1FontSizeMultiplier) {
return mojom::blink::AIPageContentTextSize::kXL;
} else if (font_size_multiplier >= kHeading3FontSizeMultiplier &&
font_size_multiplier < kHeading1FontSizeMultiplier) {
return mojom::blink::AIPageContentTextSize::kL;
} else if (font_size_multiplier >= kHeading5FontSizeMultiplier &&
font_size_multiplier < kHeading3FontSizeMultiplier) {
return mojom::blink::AIPageContentTextSize::kM;
} else if (font_size_multiplier >= kHeading6FontSizeMultiplier &&
font_size_multiplier < kHeading5FontSizeMultiplier) {
return mojom::blink::AIPageContentTextSize::kS;
} else { // font_size_multiplier < kHeading6FontSizeMultiplier
return mojom::blink::AIPageContentTextSize::kXS;
}
}
// If the style has a non-normal font weight, has applied text decorations, or
// is a super/subscript, then the text is considered to have emphasis.
bool HasEmphasis(const ComputedStyle& style) {
return style.GetFontWeight() != kNormalWeightValue ||
style.GetFontStyle() != kNormalSlopeValue ||
style.HasAppliedTextDecorations() ||
style.VerticalAlign() == EVerticalAlign::kSub ||
style.VerticalAlign() == EVerticalAlign::kSuper;
}
RGBA32 GetColor(const ComputedStyle& style) {
return style.VisitedDependentColor(GetCSSPropertyColor()).Rgb();
}
const LayoutIFrame* GetIFrame(const LayoutObject& object) {
return DynamicTo<LayoutIFrame>(object);
}
std::optional<DOMNodeId> GetDomNodeId(const LayoutObject& object) {
auto* node = object.GetNode();
if (object.IsLayoutView()) {
node = &object.GetDocument();
}
if (!node) {
return std::nullopt;
}
return DOMNodeIds::IdForNode(node);
}
bool IsVisible(const LayoutObject& object) {
// Don't add content when node is invisible.
return object.Style()->Visibility() == EVisibility::kVisible;
}
void AddClickabilityReasons(
const Element& element,
const ax::mojom::Role role,
mojom::blink::AIPageContentNodeInteractionInfo& interaction_info) {
using Reason = mojom::blink::AIPageContentClickabilityReason;
if (element.IsClickableFormControlNode()) {
interaction_info.clickability_reasons.push_back(Reason::kClickableControl);
}
if (element.HasJSBasedEventListeners(event_type_names::kClick)) {
interaction_info.clickability_reasons.push_back(Reason::kClickEvents);
}
const bool has_mouse_hover =
element.HasJSBasedEventListeners(event_type_names::kMouseover) ||
element.HasJSBasedEventListeners(event_type_names::kMouseenter);
const bool has_mouse_click =
element.HasJSBasedEventListeners(event_type_names::kMouseup) ||
element.HasJSBasedEventListeners(event_type_names::kMousedown);
if (has_mouse_hover) {
interaction_info.clickability_reasons.push_back(Reason::kMouseHover);
}
if (has_mouse_click) {
interaction_info.clickability_reasons.push_back(Reason::kMouseClick);
}
// TODO(linnan): Remove this once consumers move to use kMouseClick and
// kMouseHover.
if (has_mouse_hover || has_mouse_click) {
interaction_info.clickability_reasons.push_back(Reason::kMouseEvents);
}
if (element.HasJSBasedEventListeners(event_type_names::kKeydown) ||
element.HasJSBasedEventListeners(event_type_names::kKeypress) ||
element.HasJSBasedEventListeners(event_type_names::kKeyup)) {
interaction_info.clickability_reasons.push_back(Reason::kKeyEvents);
}
if (IsEditable(element)) {
interaction_info.clickability_reasons.push_back(Reason::kEditable);
}
const ComputedStyle& style = element.ComputedStyleRef();
if (style.Cursor() == ECursor::kPointer && !style.CursorIsInherited()) {
interaction_info.clickability_reasons.push_back(Reason::kCursorPointer);
}
if (style.AffectedByHover()) {
interaction_info.clickability_reasons.push_back(Reason::kHoverPseudoClass);
}
if (ui::IsClickable(role)) {
interaction_info.clickability_reasons.push_back(Reason::kAriaRole);
}
if (AXObject::HasPopupFromAttribute(element)) {
interaction_info.clickability_reasons.push_back(Reason::kAriaHasPopup);
}
bool aria_expanded = false;
if (AXObject::AriaBooleanAttribute(element, html_names::kAriaExpandedAttr,
&aria_expanded)) {
if (aria_expanded) {
interaction_info.clickability_reasons.push_back(
Reason::kAriaExpandedTrue);
} else {
interaction_info.clickability_reasons.push_back(
Reason::kAriaExpandedFalse);
}
}
const auto& autocomplete =
element.FastGetAttribute(html_names::kAutocompleteAttr);
const auto& aria_autocomplete =
element.FastGetAttribute(html_names::kAriaAutocompleteAttr);
if ((autocomplete && autocomplete != "off") ||
(aria_autocomplete == "inline" || aria_autocomplete == "list" ||
aria_autocomplete == "both")) {
interaction_info.clickability_reasons.push_back(Reason::kAutocomplete);
}
if (element.HasTabIndexWasSetExplicitly()) {
interaction_info.clickability_reasons.push_back(Reason::kTabIndex);
}
}
bool ShouldSkipSubtree(const LayoutObject& object) {
auto* layout_embedded_content = DynamicTo<LayoutEmbeddedContent>(object);
if (layout_embedded_content) {
auto* layout_iframe = GetIFrame(object);
// Skip embedded content that is not an iframe.
// TODO(crbug.com/381273397): Add content for embed and object.
if (!layout_iframe) {
return true;
}
// Skip iframe nodes which don't have a Document.
if (!layout_iframe->ChildFrameView()) {
return true;
}
}
// List markers are communicated by the kOrderedList and kUnorderedList
// annotated roles.
if (object.IsListMarker()) {
return true;
}
// Skip empty text.
auto* layout_text = DynamicTo<LayoutText>(object);
if (layout_text && layout_text->IsAllCollapsibleWhitespace()) {
return true;
}
return false;
}
bool ShouldSkipDescendants(
const mojom::blink::AIPageContentNodePtr& content_node) {
if (!content_node) {
return false;
}
// If the child is an iframe, it does its own tree walk.
// TODO(crbug.com/405173553): Moving ProcessIframe here might simplify
// tree construction and keep stack depth counting in one place.
if (content_node->content_attributes->attribute_type ==
mojom::blink::AIPageContentAttributeType::kIframe) {
return true;
}
// We don't capture the SVG layout internally so there's no need to
// walk their tree.
if (content_node->content_attributes->attribute_type ==
mojom::blink::AIPageContentAttributeType::kSVG) {
return true;
}
// There's no layout nodes under a canvas, the content is just the
// canvas buffer.
if (content_node->content_attributes->attribute_type ==
mojom::blink::AIPageContentAttributeType::kCanvas) {
return true;
}
// Ensure that password editor subtrees are skipped even when the password
// is revealed.
if (content_node->content_attributes->form_control_data &&
content_node->content_attributes->form_control_data->redaction_decision ==
mojom::blink::AIPageContentRedactionDecision::
kRedacted_HasBeenPassword) {
return true;
}
return false;
}
void ProcessTextNode(const LayoutText& layout_text,
mojom::blink::AIPageContentAttributes& attributes,
const ComputedStyle& document_style) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kText;
CHECK(IsVisible(layout_text));
auto text_style = mojom::blink::AIPageContentTextStyle::New();
text_style->text_size = GetTextSize(*layout_text.Style(), document_style);
text_style->has_emphasis = HasEmphasis(*layout_text.Style());
text_style->color = GetColor(*layout_text.Style());
auto text_info = mojom::blink::AIPageContentTextInfo::New();
text_info->text_content = layout_text.TransformedText();
text_info->text_style = std::move(text_style);
attributes.text_info = std::move(text_info);
}
void ProcessImageNode(const LayoutImage& layout_image,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kImage;
CHECK(IsVisible(layout_image));
// LayoutImage is a superclass of LayoutMedia, which is a superclass of
// LayoutVideo and LayoutAudio. We only want to process images here, so
// we enforce that the object is not a media object.
CHECK(!layout_image.IsMedia());
auto image_info = mojom::blink::AIPageContentImageInfo::New();
if (auto* image_element =
DynamicTo<HTMLImageElement>(layout_image.GetNode())) {
// TODO(crbug.com/383127202): A11y stack generates alt text using image
// data which could be reused for this.
image_info->image_caption = image_element->AltText();
}
// TODO(crbug.com/382558422): Include image source origin.
attributes.image_info = std::move(image_info);
}
void ProcessSVGNode(const LayoutSVGRoot& layout_svg,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kSVG;
CHECK(IsVisible(layout_svg));
auto* element = DynamicTo<Element>(layout_svg.GetNode());
if (!element) {
return;
}
auto svg_data = mojom::blink::AIPageContentSVGData::New();
svg_data->inner_text = element->GetInnerTextWithoutUpdate();
attributes.svg_data = std::move(svg_data);
}
void ProcessCanvasNode(const LayoutHTMLCanvas& layout_canvas,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kCanvas;
CHECK(IsVisible(layout_canvas));
auto canvas_data = mojom::blink::AIPageContentCanvasData::New();
canvas_data->layout_size = ToRoundedSize(layout_canvas.StitchedSize());
attributes.canvas_data = std::move(canvas_data);
}
void ProcessVideoNode(const HTMLVideoElement& video_element,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kVideo;
if (!IsVisible(*video_element.GetLayoutObject())) {
return;
}
auto video_data = mojom::blink::AIPageContentVideoData::New();
video_data->url = video_element.SourceURL();
// TODO(crbug.com/382558422): Include video source origin.
attributes.video_data = std::move(video_data);
}
void ProcessAnchorNode(const HTMLAnchorElement& anchor_element,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kAnchor;
if (!IsVisible(*anchor_element.GetLayoutObject())) {
return;
}
auto anchor_data = mojom::blink::AIPageContentAnchorData::New();
anchor_data->url = anchor_element.Url();
for (unsigned i = 0; i < anchor_element.relList().length(); ++i) {
anchor_data->rel.push_back(GetAnchorRel(anchor_element.relList().item(i)));
}
attributes.anchor_data = std::move(anchor_data);
}
void ProcessTableNode(const LayoutTable& layout_table,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kTable;
if (!IsVisible(layout_table)) {
return;
}
auto table_data = mojom::blink::AIPageContentTableData::New();
for (auto* section = layout_table.FirstChild(); section;
section = section->NextSibling()) {
if (section->IsTableCaption()) {
StringBuilder table_name;
auto* caption = To<LayoutTableCaption>(section);
for (auto* child = caption->FirstChild(); child;
child = child->NextSibling()) {
if (const auto* layout_text = DynamicTo<LayoutText>(*child)) {
table_name.Append(layout_text->TransformedText());
}
}
table_data->table_name = table_name.ToString();
}
}
attributes.table_data = std::move(table_data);
}
void ProcessFormNode(const HTMLFormElement& form_element,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kForm;
if (!IsVisible(*form_element.GetLayoutObject())) {
return;
}
auto form_data = mojom::blink::AIPageContentFormData::New();
if (const auto& name = form_element.GetName()) {
form_data->form_name = name;
}
form_data->action_url = KURL(form_element.action());
attributes.form_data = std::move(form_data);
}
void ProcessFormControlNode(const HTMLFormControlElement& form_control_element,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kFormControl;
if (!IsVisible(*form_control_element.GetLayoutObject())) {
return;
}
auto form_control_data = mojom::blink::AIPageContentFormControlData::New();
form_control_data->form_control_type = form_control_element.FormControlType();
form_control_data->field_name = form_control_element.GetName();
form_control_data->is_required = form_control_element.IsRequired();
// Set the default value for redaction, and override below as appropriate.
form_control_data->redaction_decision =
mojom::blink::AIPageContentRedactionDecision::kNoRedactionNecessary;
if (const auto* text_control_element =
DynamicTo<TextControlElement>(form_control_element)) {
// Don't include password values as they are sensitive.
if (const auto* input_element =
DynamicTo<HTMLInputElement>(text_control_element)) {
if (input_element->HasBeenPasswordField()) {
form_control_data->redaction_decision =
input_element->Value().empty()
? mojom::blink::AIPageContentRedactionDecision::
kUnredacted_EmptyPassword
: mojom::blink::AIPageContentRedactionDecision::
kRedacted_HasBeenPassword;
}
}
if (form_control_data->redaction_decision !=
mojom::blink::AIPageContentRedactionDecision::
kRedacted_HasBeenPassword) {
form_control_data->field_value = text_control_element->Value();
}
form_control_data->placeholder =
text_control_element->GetPlaceholderValue();
}
if (const auto* html_input_element =
DynamicTo<HTMLInputElement>(form_control_element)) {
form_control_data->is_checked = html_input_element->Checked();
}
if (const auto* select_element =
DynamicTo<HTMLSelectElement>(form_control_element)) {
for (auto& option_element : select_element->GetOptionList()) {
auto select_option = mojom::blink::AIPageContentSelectOption::New();
select_option->value = option_element.value();
select_option->text = option_element.text();
if (select_option->text.empty()) {
select_option->text = option_element.DisplayLabel();
}
select_option->is_selected = option_element.Selected();
select_option->disabled = option_element.IsDisabledFormControl();
form_control_data->select_options.push_back(std::move(select_option));
}
}
attributes.form_control_data = std::move(form_control_data);
}
mojom::blink::AIPageContentTableRowType GetTableRowType(
const LayoutTableRow& layout_table_row) {
if (auto* section = layout_table_row.Section()) {
if (auto* table_section_element =
DynamicTo<HTMLElement>(section->GetNode())) {
if (table_section_element->HasTagName(html_names::kTheadTag)) {
return mojom::blink::AIPageContentTableRowType::kHeader;
} else if (table_section_element->HasTagName(html_names::kTfootTag)) {
return mojom::blink::AIPageContentTableRowType::kFooter;
}
}
}
return mojom::blink::AIPageContentTableRowType::kBody;
}
void ProcessTableRowNode(const LayoutTableRow& layout_table_row,
mojom::blink::AIPageContentAttributes& attributes) {
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kTableRow;
if (!IsVisible(layout_table_row)) {
return;
}
auto table_row_data = mojom::blink::AIPageContentTableRowData::New();
table_row_data->row_type = GetTableRowType(layout_table_row);
attributes.table_row_data = std::move(table_row_data);
}
// Records latency metrics for the given latency and total latency.
void RecordLatencyMetrics(base::TimeTicks start_time,
base::TimeTicks synchronous_execution_start_time,
base::TimeTicks end_time,
bool is_main_frame,
const mojom::blink::AIPageContentOptions& options) {
const base::TimeDelta latency = end_time - synchronous_execution_start_time;
const base::TimeDelta latency_with_scheduling_delay = end_time - start_time;
const auto trace_track =
perfetto::Track(base::trace_event::GetNextGlobalTraceId());
if (is_main_frame) {
UMA_HISTOGRAM_TIMES(
"OptimizationGuide.AIPageContent.RendererLatency.MainFrame", latency);
TRACE_EVENT_BEGIN("loading", "AIPageContentGenerationMainFrame",
trace_track, synchronous_execution_start_time);
} else {
UMA_HISTOGRAM_TIMES(
"OptimizationGuide.AIPageContent.RendererLatency.RemoteSubFrame",
latency);
TRACE_EVENT_BEGIN("loading", "AIPageContentGenerationRemoteSubFrame",
trace_track, synchronous_execution_start_time);
}
TRACE_EVENT_END("loading", trace_track, end_time);
if (options.on_critical_path) {
if (is_main_frame) {
UMA_HISTOGRAM_TIMES(
"OptimizationGuide.AIPageContent.RendererLatencyWithSchedulingDelay."
"Critical."
"MainFrame",
latency_with_scheduling_delay);
} else {
UMA_HISTOGRAM_TIMES(
"OptimizationGuide.AIPageContent.RendererLatencyWithSchedulingDelay."
"Critical."
"RemoteSubFrame",
latency_with_scheduling_delay);
}
} else {
if (is_main_frame) {
UMA_HISTOGRAM_TIMES(
"OptimizationGuide.AIPageContent.RendererLatencyWithSchedulingDelay."
"NonCritical."
"MainFrame",
latency_with_scheduling_delay);
} else {
UMA_HISTOGRAM_TIMES(
"OptimizationGuide.AIPageContent.RendererLatencyWithSchedulingDelay."
"NonCritical."
"RemoteSubFrame",
latency_with_scheduling_delay);
}
}
}
// Returns true if extracting the content can't be deferred until the next
// frame.
bool NeedsSyncExtraction(const mojom::blink::AIPageContentOptions& options) {
return options.on_critical_path;
}
const mojom::blink::AIPageContentNode* FindContentNode(
const mojom::blink::AIPageContentNode* current_node,
DOMNodeId target_id) {
if (!current_node) {
return nullptr;
}
if (current_node->content_attributes &&
current_node->content_attributes->dom_node_id == target_id) {
return current_node;
}
for (const auto& child : current_node->children_nodes) {
if (const mojom::blink::AIPageContentNode* found =
FindContentNode(child.get(), target_id)) {
return found;
}
}
return nullptr;
}
// Recursively traverses the content node tree, applying the given `offset` to
// all geometry fields. This is used to translate the coordinates of a subtree
// from one coordinate space to another, for example, to adjust a popup's
// geometry to be relative to the main frame's viewport.
void OffsetNodeGeometry(mojom::blink::AIPageContentNode& node,
const gfx::Vector2d& offset) {
if (node.content_attributes && node.content_attributes->geometry) {
node.content_attributes->geometry->outer_bounding_box.Offset(offset);
node.content_attributes->geometry->visible_bounding_box.Offset(offset);
for (gfx::Rect& rect :
node.content_attributes->geometry->fragment_visible_bounding_boxes) {
rect.Offset(offset);
}
}
for (mojom::blink::AIPageContentNodePtr& child : node.children_nodes) {
OffsetNodeGeometry(*child, offset);
}
}
} // namespace
// static
const unsigned AIPageContentAgent::kSupplementIndex =
static_cast<unsigned>(Document::Supplements::kAIPageContentAgent);
// static
AIPageContentAgent* AIPageContentAgent::From(Document& document) {
return Supplement<Document>::From<AIPageContentAgent>(document);
}
// static
void AIPageContentAgent::BindReceiver(
LocalFrame* frame,
mojo::PendingReceiver<mojom::blink::AIPageContentAgent> receiver) {
CHECK(frame && frame->GetDocument());
CHECK(frame->IsLocalRoot());
auto& document = *frame->GetDocument();
auto* agent = AIPageContentAgent::From(document);
if (!agent) {
agent = MakeGarbageCollected<AIPageContentAgent>(
base::PassKey<AIPageContentAgent>(), *frame);
Supplement<Document>::ProvideTo(document, agent);
}
agent->Bind(std::move(receiver));
}
// static
AIPageContentAgent* AIPageContentAgent::GetOrCreateForTesting(
Document& document) {
auto* agent = AIPageContentAgent::From(document);
if (!agent) {
DCHECK(document.GetFrame());
agent = MakeGarbageCollected<AIPageContentAgent>(
base::PassKey<AIPageContentAgent>(), *document.GetFrame());
Supplement<Document>::ProvideTo(document, agent);
}
return agent;
}
AIPageContentAgent::AIPageContentAgent(base::PassKey<AIPageContentAgent>,
LocalFrame& frame)
: Supplement<Document>(*frame.GetDocument()),
receiver_set_(this, frame.DomWindow()) {
DCHECK(frame.GetDocument());
}
AIPageContentAgent::~AIPageContentAgent() = default;
void AIPageContentAgent::Bind(
mojo::PendingReceiver<mojom::blink::AIPageContentAgent> receiver) {
receiver_set_.Add(
std::move(receiver),
GetSupplementable()->GetTaskRunner(TaskType::kInternalUserInteraction));
}
void AIPageContentAgent::Trace(Visitor* visitor) const {
visitor->Trace(receiver_set_);
Supplement<Document>::Trace(visitor);
}
void AIPageContentAgent::DidFinishPostLifecycleSteps(const LocalFrameView&) {
for (auto& task : std::move(async_extraction_tasks_)) {
std::move(task).Run();
}
async_extraction_tasks_.clear();
}
void AIPageContentAgent::GetAIPageContent(
mojom::blink::AIPageContentOptionsPtr options,
GetAIPageContentCallback callback) {
base::TimeTicks start_time = base::TimeTicks::Now();
LocalFrameView* view = GetSupplementable()->View();
// If there's no lifecycle pending, we can't rely on post lifecycle
// notifications and the layout is likely clean.
const bool can_do_sync_extraction = !view || !view->LifecycleUpdatePending();
if (can_do_sync_extraction || NeedsSyncExtraction(*options)) {
GetAIPageContentSync(std::move(options), std::move(callback), start_time);
return;
}
if (!is_registered_) {
is_registered_ = true;
view->RegisterForLifecycleNotifications(this);
}
// We don't expect many overlapping calls to this service as the browser will
// only issue one request at a time.
async_extraction_tasks_.push_back(blink::BindOnce(
&AIPageContentAgent::GetAIPageContentSync, WrapWeakPersistent(this),
std::move(options), std::move(callback), start_time));
}
void AIPageContentAgent::GetAIPageContentSync(
mojom::blink::AIPageContentOptionsPtr options,
GetAIPageContentCallback callback,
base::TimeTicks start_time) const {
const auto sync_start_time = base::TimeTicks::Now();
auto content = GetAIPageContentInternal(*options);
if (!content) {
std::move(callback).Run(nullptr);
return;
}
const auto end_time = base::TimeTicks::Now();
RecordLatencyMetrics(start_time, sync_start_time, end_time,
GetSupplementable()->GetFrame()->IsOutermostMainFrame(),
*options);
std::move(callback).Run(std::move(content));
}
String AIPageContentAgent::DumpContentNodeTreeForTest() {
mojom::blink::AIPageContentOptions options;
options.on_critical_path = true;
options.mode = mojom::blink::AIPageContentMode::kActionableElements;
auto content = GetAIPageContentInternal(options);
CHECK(content);
CHECK(content->root_node);
return ContentNodeTreeToString(content->root_node.get());
}
String AIPageContentAgent::DumpContentNodeForTest(Node* node) {
CHECK(node);
mojom::blink::AIPageContentOptions options;
options.on_critical_path = true;
options.mode = mojom::blink::AIPageContentMode::kActionableElements;
auto content = GetAIPageContentInternal(options);
CHECK(content);
CHECK(content->root_node);
DOMNodeId target_id = node->GetDomNodeId();
if (target_id == kInvalidDOMNodeId) {
return "Error: node has no DOMNodeId";
}
const mojom::blink::AIPageContentNode* found_node =
FindContentNode(content->root_node.get(), target_id);
if (!found_node) {
return "Error: content node not found for the given DOM node";
}
return ContentNodeToString(found_node, /*format_on_single_line=*/false);
}
mojom::blink::AIPageContentPtr AIPageContentAgent::GetAIPageContentInternal(
const mojom::blink::AIPageContentOptions& options) const {
LocalFrame* frame = GetSupplementable()->GetFrame();
if (!frame || !frame->GetDocument() || !frame->GetDocument()->View()) {
return nullptr;
}
ContentBuilder builder(options);
return builder.Build(*frame);
}
AIPageContentAgent::ContentBuilder::ContentBuilder(
const mojom::blink::AIPageContentOptions& options)
: options_(options) {}
AIPageContentAgent::ContentBuilder::~ContentBuilder() = default;
mojom::blink::AIPageContentPtr AIPageContentAgent::ContentBuilder::Build(
LocalFrame& frame) {
TRACE_EVENT0("blink", "AIPageContentAgent::ContentBuilder::Build");
auto& document = *frame.GetDocument();
mojom::blink::AIPageContentPtr page_content =
mojom::blink::AIPageContent::New();
// Force activatable locks so content which is accessible via find-in-page is
// styled/laid out and included when walking the tree below.
//
// TODO(crbug.com/387355768): Consider limiting the lock to nodes with
// activation reason of FindInPage.
std::vector<DisplayLockDocumentState::ScopedForceActivatableDisplayLocks>
forced_activatable_locks;
// If we're doing this extraction as a part of the document lifecycle, we
// can't invalidate style/layout.
if (!document.InvalidationDisallowed()) {
forced_activatable_locks.emplace_back(
document.GetDisplayLockDocumentState()
.GetScopedForceActivatableLocks());
document.View()->ForAllChildLocalFrameViews(
[&](LocalFrameView& frame_view) {
if (!frame_view.GetFrame().GetDocument()) {
return;
}
forced_activatable_locks.emplace_back(
frame_view.GetFrame()
.GetDocument()
->GetDisplayLockDocumentState()
.GetScopedForceActivatableLocks());
});
}
UpdateLifecycle(document);
auto* layout_view = document.GetLayoutView();
auto* document_style = layout_view->Style();
// Add nodes which have a currently active user interaction (selection, focus
// etc) before walking the tree to ensure we promote interactive DOM nodes to
// ContentNodes.
//
// Note: This is different from `NodeInteractionInfo` which tracks whether a
// node supports any interaction.
AddPageInteractionInfo(document, *page_content);
auto frame_data = mojom::blink::AIPageContentFrameData::New();
AddFrameData(frame, *frame_data);
page_content->frame_data = std::move(frame_data);
auto root_node = MaybeGenerateContentNode(*layout_view, *document_style);
CHECK(root_node);
WalkChildren(*layout_view, *root_node, *document_style);
page_content->root_node = std::move(root_node);
if (stack_depth_exceeded_) {
ukm::builders::OptimizationGuide_AIPageContentAgent(document.UkmSourceID())
.SetNodeDepthLimitExceeded(true)
.Record(document.UkmRecorder());
}
return page_content;
}
void AIPageContentAgent::ContentBuilder::UpdateLifecycle(Document& document) {
// Running lifecycle beyond layout is expensive and the information is only
// needed to compute geometry. Limit the update to layout if we don't need
// the geometry.
if (actionable_mode()) {
document.View()->UpdateAllLifecyclePhasesExceptPaint(
DocumentUpdateReason::kUnknown);
} else {
document.View()->UpdateLifecycleToLayoutClean(
DocumentUpdateReason::kUnknown);
}
// If there's any popup opened, update the popup as well.
WebViewImpl* web_view = document.GetPage()->GetChromeClient().GetWebView();
WebPagePopupImpl* web_popup = web_view->GetPagePopup();
if (!web_popup) {
return;
}
LocalDOMWindow* popup_window = web_popup->Window();
if (!popup_window) {
return;
}
LocalFrame* popup_frame = popup_window->GetFrame();
if (!popup_frame) {
return;
}
Document* popup_document = popup_frame->GetDocument();
if (!popup_document) {
return;
}
if (actionable_mode()) {
popup_document->View()->UpdateAllLifecyclePhasesExceptPaint(
DocumentUpdateReason::kUnknown);
} else {
popup_document->View()->UpdateLifecycleToLayoutClean(
DocumentUpdateReason::kUnknown);
}
}
void AIPageContentAgent::ContentBuilder::AddMetaData(
const LocalFrame& frame,
Vector<mojom::blink::AIPageContentMetaPtr>& meta_data) const {
int max = options_->max_meta_elements;
if (max == 0) {
return;
}
int count = 0;
const HTMLHeadElement* head = frame.GetDocument()->head();
if (!head) {
return;
}
for (HTMLMetaElement& meta_element :
Traversal<HTMLMetaElement>::ChildrenOf(*head)) {
auto name = meta_element.GetName();
if (name.empty()) {
continue;
}
auto meta = mojom::blink::AIPageContentMeta::New();
meta->name = name;
auto content = meta_element.Content();
if (content.empty()) {
meta->content = "";
} else {
meta->content = content;
}
meta_data.push_back(std::move(meta));
count++;
if (count >= max) {
break;
}
}
}
bool AIPageContentAgent::ContentBuilder::IsGenericContainer(
const LayoutObject& object,
const mojom::blink::AIPageContentAttributes& attributes) const {
if (object.Style()->GetPosition() == EPosition::kFixed) {
return true;
}
if (object.Style()->GetPosition() == EPosition::kSticky) {
return true;
}
// This has some duplication with the scrollability in InteractionInfo but is
// still required for 2 reasons:
// 1. The interaction info is only computed when actionable elements are
// requested.
// 2. The interaction info is meant to capture the current state (is the
// element scrollable given the current content). This is a heuristic to
// decide whether a node is likely to be a "container" based on the author
// making it scrollable.
// TODO(khushalsagar): Consider removing this, no consumer relies on this
// behaviour.
if (object.Style()->ScrollsOverflow()) {
return true;
}
if (object.IsInTopOrViewTransitionLayer()) {
return true;
}
if (const auto* element = DynamicTo<HTMLElement>(object.GetNode())) {
if (element->HasTagName(html_names::kFigureTag)) {
return true;
}
}
if (!attributes.annotated_roles.empty()) {
return true;
}
if (attributes.node_interaction_info) {
return true;
}
if (attributes.label_for_dom_node_id) {
return true;
}
// Use `ExistingIdForNode` since an Id should have already been generated if
// this node is interactive.
if (interactive_dom_node_ids_.contains(
DOMNodeIds::ExistingIdForNode(object.GetNode()))) {
return true;
}
return false;
}
void AIPageContentAgent::ContentBuilder::AddInteractiveNode(
DOMNodeId dom_node_id) {
CHECK_NE(dom_node_id, kInvalidDOMNodeId);
interactive_dom_node_ids_.insert(dom_node_id);
}
bool AIPageContentAgent::ContentBuilder::WalkChildren(
const LayoutObject& object,
mojom::blink::AIPageContentNode& content_node,
const RecursionData& recursion_data) {
if (object.ChildPrePaintBlockedByDisplayLock()) {
return false;
}
// The max tree depth is the mojo kMaxRecursionDepth minus a buffer to leave
// room for the root node, attributes of the final node, and mojo wrappers
// used in message creation.
static const int kMaxTreeDepth = kMaxRecursionDepth - 8;
if (recursion_data.stack_depth > kMaxTreeDepth) {
stack_depth_exceeded_ = true;
return false;
}
bool has_visible_content = false;
for (auto* child = object.SlowFirstChild(); child;
child = child->NextSibling()) {
if (ShouldSkipSubtree(*child)) {
continue;
}
RecursionData child_recursion_data(recursion_data);
auto* child_element = DynamicTo<Element>(child->GetNode());
if (!child_recursion_data.is_aria_disabled && child_element &&
AXObject::IsAriaAttributeTrue(*child_element,
html_names::kAriaDisabledAttr)) {
child_recursion_data.is_aria_disabled = true;
}
has_visible_content |= IsVisible(*child);
bool child_has_visible_content = false;
auto child_content_node =
MaybeGenerateContentNode(*child, child_recursion_data);
if (!ShouldSkipDescendants(child_content_node)) {
if (child_content_node) {
child_recursion_data.stack_depth++;
}
auto& node_for_child =
child_content_node ? *child_content_node : content_node;
child_has_visible_content =
WalkChildren(*child, node_for_child, child_recursion_data);
has_visible_content |= child_has_visible_content;
}
const bool should_add_node_for_child =
IsVisible(*child) || child_has_visible_content;
if (should_add_node_for_child && child_content_node) {
content_node.children_nodes.emplace_back(std::move(child_content_node));
}
}
return has_visible_content;
}
void AIPageContentAgent::ContentBuilder::ProcessIframe(
const LayoutIFrame& object,
mojom::blink::AIPageContentNode& content_node,
const RecursionData& recursion_data) {
CHECK(IsVisible(object));
content_node.content_attributes->attribute_type =
mojom::blink::AIPageContentAttributeType::kIframe;
auto& frame = object.ChildFrameView()->GetFrame();
auto iframe_data = mojom::blink::AIPageContentIframeData::New();
iframe_data->frame_token = frame.GetFrameToken();
content_node.content_attributes->iframe_data = std::move(iframe_data);
auto* local_frame = DynamicTo<LocalFrame>(frame);
if (!local_frame) {
return;
}
if (options_->include_same_site_only && !frame.IsOutermostMainFrame()) {
const SecurityOrigin* frame_origin =
local_frame->GetSecurityContext()->GetSecurityOrigin();
const SecurityOrigin* main_frame_origin =
local_frame->Top()->GetSecurityContext()->GetSecurityOrigin();
CHECK(frame_origin);
CHECK(main_frame_origin);
if (!frame_origin->IsSameSiteWith(main_frame_origin)) {
content_node.content_attributes->iframe_data->content =
mojom::blink::AIPageContentIframeContent::NewRedactedFrameMetadata(
mojom::blink::RedactedFrameMetadata::New(
mojom::blink::RedactedFrameMetadata::Reason::kCrossSite));
return;
}
}
// Add interaction metadata before walking the tree to ensure we promote
// interactive DOM nodes to ContentNodes.
if (local_frame->GetDocument()) {
auto frame_data = mojom::blink::AIPageContentFrameData::New();
AddFrameData(*local_frame, *frame_data);
content_node.content_attributes->iframe_data->content =
mojom::blink::AIPageContentIframeContent::NewLocalFrameData(
std::move(frame_data));
}
auto* child_layout_view = local_frame->ContentLayoutObject();
if (child_layout_view) {
RecursionData child_recursion_data(*child_layout_view->Style());
// The aria attribute values don't pierce frame boundaries.
child_recursion_data.is_aria_disabled = false;
child_recursion_data.stack_depth = recursion_data.stack_depth + 1;
// Add a node for the iframe's LayoutView for consistency with remote
// frames.
auto child_content_node =
MaybeGenerateContentNode(*child_layout_view, child_recursion_data);
CHECK(child_content_node);
// We could consider removing an iframe with no visible content. But this
// is likely not common and should be done in the browser so it's
// consistently done for local and remote frames.
WalkChildren(*child_layout_view, *child_content_node, child_recursion_data);
content_node.children_nodes.emplace_back(std::move(child_content_node));
}
}
mojom::blink::AIPageContentNodePtr
AIPageContentAgent::ContentBuilder::MaybeGenerateContentNode(
const LayoutObject& object,
const RecursionData& recursion_data) {
auto content_node = mojom::blink::AIPageContentNode::New();
content_node->content_attributes =
mojom::blink::AIPageContentAttributes::New();
mojom::blink::AIPageContentAttributes& attributes =
*content_node->content_attributes;
// Compute state that is used to decide whether this node generates a
// ContentNode before making the decision below.
AddAnnotatedRoles(object, attributes.annotated_roles);
AddForDomNodeId(object, attributes);
// Interaction info depends on aria role.
AddAriaRole(object, attributes);
AddNodeInteractionInfo(object, attributes, recursion_data.is_aria_disabled);
// Set the attribute type and add any special attributes if the attribute type
// requires it.
auto* element = DynamicTo<HTMLElement>(object.GetNode());
if (const auto* iframe = GetIFrame(object)) {
// If the `iframe` is invisible, it's Document can't override this and must
// also be invisible.
if (!IsVisible(object)) {
return nullptr;
}
ProcessIframe(*iframe, *content_node, recursion_data);
} else if (object.IsLayoutView()) {
attributes.attribute_type = mojom::blink::AIPageContentAttributeType::kRoot;
} else if (object.IsText()) {
// Since text is a leaf node, do not create a content node if should skip
// content.
if (!IsVisible(object)) {
return nullptr;
}
ProcessTextNode(To<LayoutText>(object), attributes,
recursion_data.document_style);
} else if (object.IsImage()) {
// Since image is a leaf node, do not create a content node if should skip
// content.
if (!IsVisible(object)) {
return nullptr;
}
ProcessImageNode(To<LayoutImage>(object), attributes);
} else if (object.IsSVGRoot()) {
// Since we add the full text under SVG directly, don't add anything if the
// SVG is hidden.
if (!IsVisible(object)) {
return nullptr;
}
ProcessSVGNode(To<LayoutSVGRoot>(object), attributes);
} else if (object.IsCanvas()) {
// No content will be rendered if the canvas is hidden.
if (!IsVisible(object)) {
return nullptr;
}
ProcessCanvasNode(To<LayoutHTMLCanvas>(object), attributes);
} else if (const auto* video_element =
DynamicTo<HTMLVideoElement>(object.GetNode())) {
ProcessVideoNode(*video_element, attributes);
} else if (const auto* anchor_element =
DynamicTo<HTMLAnchorElement>(object.GetNode())) {
ProcessAnchorNode(*anchor_element, attributes);
} else if (object.IsTable()) {
ProcessTableNode(To<LayoutTable>(object), attributes);
} else if (object.IsTableRow()) {
ProcessTableRowNode(To<LayoutTableRow>(object), attributes);
} else if (object.IsTableCell()) {
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kTableCell;
} else if (const auto* form_element =
DynamicTo<HTMLFormElement>(object.GetNode())) {
ProcessFormNode(*form_element, attributes);
} else if (const auto* form_control =
DynamicTo<HTMLFormControlElement>(object.GetNode())) {
ProcessFormControlNode(*form_control, attributes);
} else if (element && IsHeadingTag(*element)) {
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kHeading;
} else if (element && element->HasTagName(html_names::kPTag)) {
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kParagraph;
} else if (element && element->HasTagName(html_names::kOlTag)) {
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kOrderedList;
} else if (element && (element->HasTagName(html_names::kUlTag) ||
element->HasTagName(html_names::kDlTag))) {
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kUnorderedList;
} else if (element && (element->HasTagName(html_names::kLiTag) ||
element->HasTagName(html_names::kDtTag) ||
element->HasTagName(html_names::kDdTag))) {
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kListItem;
} else if (IsGenericContainer(object, attributes)) {
// Be sure to set annotated roles before calling IsGenericContainer, as
// IsGenericContainer will check for annotated roles.
// Keep container at the bottom of the list as it is the least specific.
attributes.attribute_type =
mojom::blink::AIPageContentAttributeType::kContainer;
} else {
// If no attribute type was set, do not generate a content node.
return nullptr;
}
if (auto dom_node_id = GetDomNodeId(object)) {
attributes.dom_node_id = *dom_node_id;
}
AddNodeGeometry(object, attributes);
AddLabel(object, attributes);
attributes.is_ad_related = element && element->IsAdRelated();
return content_node;
}
void AIPageContentAgent::ContentBuilder::AddLabel(
const LayoutObject& object,
mojom::blink::AIPageContentAttributes& attributes) const {
if (!actionable_mode()) {
return;
}
auto* element = DynamicTo<Element>(object.GetNode());
if (!element) {
return;
}
// TODO(khushalsagar): Look at `AXNodeObject::TextAlternative` which has other
// sources for this.
StringBuilder accumulated_text;
const auto& aria_label =
element->FastGetAttribute(html_names::kAriaLabelAttr);
if (!aria_label.GetString().ContainsOnlyWhitespaceOrEmpty()) {
accumulated_text.Append(aria_label);
}
const GCedHeapVector<Member<Element>>* aria_labelledby_elements =
element->ElementsFromAttributeOrInternals(
html_names::kAriaLabelledbyAttr);
if (!aria_labelledby_elements) {
attributes.label = accumulated_text.ToString();
return;
}
for (const auto& label_element : *aria_labelledby_elements) {
// We need to use textContent instead of innerText since aria labelled by
// nodes don't need to be in the layout.
auto text_content = label_element->textContent(true);
if (text_content.ContainsOnlyWhitespaceOrEmpty()) {
continue;
}
if (!accumulated_text.empty()) {
accumulated_text.Append(" ");
}
accumulated_text.Append(text_content);
}
attributes.label = accumulated_text.ToString();
}
void AIPageContentAgent::ContentBuilder::AddForDomNodeId(
const LayoutObject& object,
mojom::blink::AIPageContentAttributes& attributes) const {
if (!actionable_mode()) {
return;
}
auto* label = DynamicTo<HTMLLabelElement>(object.GetNode());
if (!label) {
return;
}
auto* control = label->Control();
if (!control) {
return;
}
attributes.label_for_dom_node_id = DOMNodeIds::IdForNode(control);
}
void AIPageContentAgent::ContentBuilder::AddAnnotatedRoles(
const LayoutObject& object,
Vector<mojom::blink::AIPageContentAnnotatedRole>& annotated_roles) const {
const auto& style = object.StyleRef();
if (style.ContentVisibility() == EContentVisibility::kHidden) {
annotated_roles.push_back(
mojom::blink::AIPageContentAnnotatedRole::kContentHidden);
}
// Element specific roles below.
const auto* element = DynamicTo<HTMLElement>(object.GetNode());
if (!element) {
return;
}
if (element->HasTagName(html_names::kHeaderTag) ||
element->FastGetAttribute(html_names::kRoleAttr) == "banner") {
annotated_roles.push_back(
mojom::blink::AIPageContentAnnotatedRole::kHeader);
}
if (element->HasTagName(html_names::kNavTag) ||
element->FastGetAttribute(html_names::kRoleAttr) == "navigation") {
annotated_roles.push_back(mojom::blink::AIPageContentAnnotatedRole::kNav);
}
if (element->HasTagName(html_names::kSearchTag) ||
element->FastGetAttribute(html_names::kRoleAttr) == "search") {
annotated_roles.push_back(
mojom::blink::AIPageContentAnnotatedRole::kSearch);
}
if (element->HasTagName(html_names::kMainTag) ||
element->FastGetAttribute(html_names::kRoleAttr) == "main") {
annotated_roles.push_back(mojom::blink::AIPageContentAnnotatedRole::kMain);
}
if (element->HasTagName(html_names::kArticleTag) ||
element->FastGetAttribute(html_names::kRoleAttr) == "article") {
annotated_roles.push_back(
mojom::blink::AIPageContentAnnotatedRole::kArticle);
}
if (element->HasTagName(html_names::kSectionTag) ||
element->FastGetAttribute(html_names::kRoleAttr) == "region") {
annotated_roles.push_back(
mojom::blink::AIPageContentAnnotatedRole::kSection);
}
if (element->HasTagName(html_names::kAsideTag) ||
element->FastGetAttribute(html_names::kRoleAttr) == "complementary") {
annotated_roles.push_back(mojom::blink::AIPageContentAnnotatedRole::kAside);
}
if (element->HasTagName(html_names::kFooterTag) ||
element->FastGetAttribute(html_names::kRoleAttr) == "contentinfo") {
annotated_roles.push_back(
mojom::blink::AIPageContentAnnotatedRole::kFooter);
}
if (paid_content_.IsPaidElement(element)) {
annotated_roles.push_back(
mojom::blink::AIPageContentAnnotatedRole::kPaidContent);
}
}
void AIPageContentAgent::ContentBuilder::AddNodeGeometry(
const LayoutObject& object,
mojom::blink::AIPageContentAttributes& attributes) const {
// When in non-actionable mode, we only want to add geometry for the
// accessibility focused node.
if (!actionable_mode() &&
attributes.dom_node_id != accessibility_focused_node_id_) {
return;
}
// Layout must be complete before computing geometry.
DCHECK(object.GetDocument().Lifecycle().GetState() >=
DocumentLifecycle::kLayoutClean)
<< "AddNodeGeometry only works when layout is complete for object: "
<< object;
attributes.geometry = mojom::blink::AIPageContentGeometry::New();
mojom::blink::AIPageContentGeometry& geometry = *attributes.geometry;
// Compute the two fundamental bounding boxes:
//
// 1. outer_bounding_box: The object's full bounding box in viewport
// coordinates, ignoring all ancestor clipping (including the viewport
// clip). This includes the entire object regardless of viewport
// visibility. The origin is relative to the viewport; negative values
// indicate the object begins above/left of the viewport.
//
// 2. visible_bounding_box: The portion visible in the viewport, expressed in
// viewport coordinates after applying all ancestor and viewport clipping.
//
// These boxes serve different purposes:
// - outer_bounding_box: Used for hit-testing semantics and determining the
// object’s overall size and position relative to the viewport.
// - visible_bounding_box: Used for determining what is actually visible to
// users and immediately hit-testable without scrolling.
geometry.outer_bounding_box = ComputeOuterBoundingBox(object);
geometry.visible_bounding_box = ComputeVisibleBoundingBox(object);
// Validate the relationship between outer and visible bounding boxes
// TODO(aleventhal): restore for Canary builds.
#if DCHECK_IS_ON() && !defined(OFFICIAL_BUILD)
ValidateBoundingBoxes(geometry.outer_bounding_box,
geometry.visible_bounding_box, object);
#endif
// Compute fragment bounding boxes for objects that split across multiple
// lines or containers (fragmentation). This happens when:
// - Text wraps across multiple lines
// - Content splits across columns (CSS multi-column layout)
//
// Fragment boxes help understand the visual layout of split content.
ComputeFragmentBoundingBoxes(object, geometry);
geometry.is_fixed_or_sticky_position =
object.Style()->GetPosition() == EPosition::kFixed ||
object.Style()->GetPosition() == EPosition::kSticky;
}
void AIPageContentAgent::ContentBuilder::ComputeHitTestableNodesInViewport(
const LocalFrame& frame) {
if (!actionable_mode()) {
return;
}
const Document& document = *frame.GetDocument();
if (!document.GetLayoutView()) {
return;
}
const auto viewport_rect =
ComputeVisibleBoundingBox(*document.GetLayoutView());
if (viewport_rect.IsEmpty()) {
return;
}
const auto local_visible_viewport_rect =
document.GetLayoutView()->AbsoluteToLocalRect(PhysicalRect(viewport_rect),
kMapToViewportFlags);
HitTestLocation location(local_visible_viewport_rect);
std::vector<DOMNodeId> hit_nodes;
HitTestRequest::HitNodeCb hit_node_cb =
BindRepeating(&CollectHitTestNodes, std::ref(hit_nodes));
HitTestRequest request(
HitTestRequest::kReadOnly | HitTestRequest::kActive |
HitTestRequest::kListBased | HitTestRequest::kPenetratingList |
HitTestRequest::kAvoidCache | HitTestRequest::kHitNodeCbWithId,
nullptr, std::move(hit_node_cb));
HitTestResult result(request, location);
document.GetLayoutView()->HitTest(location, result);
// TODO(averge): At this point, hit_nodes may contain duplicates due to
// multiple passes over the same node while hit testing. These need to
// be filtered out. The most correct approach is probably to keep the first
// occurrence of each node, because it's more likely it was added in a later
// paint phase, which is more representative of what the page actually looks
// like to the user (or actor).
//
// result.ListBasedTestResult() already returns a NodeSet with predictable
// iteration order based on order of insertion, which is a fancy way of saying
// it already handles duplicates in exactly the way we need. We should eval
// using the NodeSet result directly, and if we see improvement, remove
// hit_nodes and the associated callback entirely.
if (base::FeatureList::IsEnabled(
blink::features::kAIPageContentZOrderEarlyFiltering)) {
std::vector<DOMNodeId> nodes_from_result;
for (auto& gc_member : result.ListBasedTestResult()) {
Node& node = *gc_member;
if (node.GetLayoutObject()) {
nodes_from_result.push_back(DOMNodeIds::IdForNode(&node));
}
}
hit_nodes = nodes_from_result;
}
int32_t next_z_order = 1;
for (DOMNodeId node_id : base::Reversed(hit_nodes)) {
if (dom_node_to_z_order_.Contains(node_id)) {
continue;
}
auto* node = DOMNodeIds::NodeForId(node_id);
CHECK(node);
if (!node->IsDocumentNode() &&
!document.ElementForHitTest(node,
TreeScope::HitTestPointType::kInternal)) {
continue;
}
dom_node_to_z_order_.insert(node_id, next_z_order++);
}
}
void AIPageContentAgent::ContentBuilder::AddPageInteractionInfo(
const Document& document,
mojom::blink::AIPageContent& page_content) {
page_content.page_interaction_info =
mojom::blink::AIPageContentPageInteractionInfo::New();
mojom::blink::AIPageContentPageInteractionInfo& page_interaction_info =
*page_content.page_interaction_info;
// Focused element
if (Element* element = document.FocusedElement()) {
page_interaction_info.focused_dom_node_id = DOMNodeIds::IdForNode(element);
AddInteractiveNode(*page_interaction_info.focused_dom_node_id);
}
// Accessibility focus
if (AXObjectCache* ax_object_cache = document.ExistingAXObjectCache()) {
if (Node* ax_focused_node = ax_object_cache->GetAccessibilityFocus()) {
accessibility_focused_node_id_ = DOMNodeIds::IdForNode(ax_focused_node);
page_interaction_info.accessibility_focused_dom_node_id =
accessibility_focused_node_id_;
AddInteractiveNode(
*page_interaction_info.accessibility_focused_dom_node_id);
}
}
// Mouse location
LocalFrame* frame = document.GetFrame();
CHECK(frame);
EventHandler& event_handler = frame->GetEventHandler();
page_interaction_info.mouse_position =
gfx::ToRoundedPoint(event_handler.LastKnownMousePositionInRootFrame());
}
void AIPageContentAgent::ContentBuilder::AddFrameData(
LocalFrame& frame,
mojom::blink::AIPageContentFrameData& frame_data) {
frame_data.frame_interaction_info =
mojom::blink::AIPageContentFrameInteractionInfo::New();
frame_data.title = frame.GetDocument()->title();
AddFrameInteractionInfo(frame, *frame_data.frame_interaction_info);
AddMetaData(frame, frame_data.meta_data);
if (RuntimeEnabledFeatures::AIPageContentPaidContentAnnotationEnabled()) {
if (paid_content_.QueryPaidElements(*frame.GetDocument())) {
frame_data.contains_paid_content = true;
}
}
ComputeHitTestableNodesInViewport(frame);
if (auto* model_context = ModelContextSupplement::GetIfExists(
*frame.DomWindow()->navigator())) {
model_context->ForEachScriptTool([&](const mojom::blink::ScriptTool& tool) {
frame_data.script_tools.push_back(tool.Clone());
});
}
MaybeAddPopupData(frame, frame_data);
}
void AIPageContentAgent::ContentBuilder::AddFrameInteractionInfo(
const LocalFrame& frame,
mojom::blink::AIPageContentFrameInteractionInfo& frame_interaction_info) {
// Selection
if (!frame.SelectedText().empty()) {
frame_interaction_info.selection =
mojom::blink::AIPageContentSelection::New();
mojom::blink::AIPageContentSelection& selection =
*frame_interaction_info.selection;
selection.selected_text = frame.SelectedText();
const SelectionInDOMTree& frame_selection =
frame.Selection().GetSelectionInDOMTree();
const Position& start_position = frame_selection.ComputeStartPosition();
const Position& end_position = frame_selection.ComputeEndPosition();
Node* start_node = start_position.ComputeContainerNode();
Node* end_node = end_position.ComputeContainerNode();
if (start_node) {
selection.start_dom_node_id = DOMNodeIds::IdForNode(start_node);
AddInteractiveNode(selection.start_dom_node_id);
selection.start_offset = start_position.ComputeOffsetInContainerNode();
}
if (end_node) {
selection.end_dom_node_id = DOMNodeIds::IdForNode(end_node);
AddInteractiveNode(selection.end_dom_node_id);
selection.end_offset = end_position.ComputeOffsetInContainerNode();
}
}
}
void AIPageContentAgent::ContentBuilder::MaybeAddPopupData(
LocalFrame& frame,
mojom::blink::AIPageContentFrameData& frame_data) {
if (!base::FeatureList::IsEnabled(
blink::features::kAIPageContentIncludePopupWindows)) {
return;
}
// Check for an open popup window.
WebViewImpl* web_view = frame.GetPage()->GetChromeClient().GetWebView();
if (!web_view->HasOpenedPopup()) {
return;
}
// Fetch the popup window and the element that opened it.
WebPagePopupImpl* web_popup = web_view->GetPagePopup();
Element& opener = web_popup->OwnerElement();
// Only fill AIPageContentPopup if this frame owns the popup.
if (opener.GetDocument() != frame.GetDocument()) {
return;
}
LocalDOMWindow* popup_window = web_popup->Window();
if (!popup_window) {
return;
}
LocalFrame* popup_frame = popup_window->GetFrame();
if (!popup_frame) {
return;
}
LayoutView* web_popup_layout_view = popup_frame->ContentLayoutObject();
if (!web_popup_layout_view) {
return;
}
ComputeHitTestableNodesInViewport(*popup_frame);
auto mojom_popup = mojom::blink::AIPageContentPopup::New();
// Build the ContentNode tree.
auto web_popup_root_node = MaybeGenerateContentNode(
*web_popup_layout_view, *web_popup_layout_view->Style());
CHECK(web_popup_root_node);
WalkChildren(*web_popup_layout_view, *web_popup_root_node,
*web_popup_layout_view->Style());
// Offset the geometry relative to the main frame.
gfx::Rect main_frame_view_rect_dips = options_->main_frame_view_rect_in_dips;
gfx::Rect popup_view_rect_in_dips =
static_cast<WebPagePopup*>(web_popup)->ViewRect();
gfx::Vector2d offset_in_dips = popup_view_rect_in_dips.OffsetFromOrigin() -
main_frame_view_rect_dips.OffsetFromOrigin();
FrameWidget* local_frame_widget = frame.GetWidgetForLocalRoot();
CHECK(local_frame_widget);
gfx::Point offset_in_pixels =
gfx::ToFlooredPoint(local_frame_widget->DIPsToBlinkSpace(
gfx::PointF(gfx::Point() + offset_in_dips)));
OffsetNodeGeometry(*web_popup_root_node, offset_in_pixels.OffsetFromOrigin());
mojom_popup->root_node = std::move(web_popup_root_node);
// Add identifier for the node which opened the popup.
mojom_popup->opener_dom_node_id = opener.GetDomNodeId();
frame_data.popup = std::move(mojom_popup);
}
void AIPageContentAgent::ContentBuilder::AddInteractionInfoForHitTesting(
const Node* node,
mojom::blink::AIPageContentNodeInteractionInfo& interaction_info) const {
if (!actionable_mode()) {
return;
}
DOMNodeId dom_node_id = DOMNodeIds::ExistingIdForNode(node);
if (dom_node_id <= kInvalidDOMNodeId) {
return;
}
auto it = dom_node_to_z_order_.find(dom_node_id);
if (it != dom_node_to_z_order_.end()) {
interaction_info.document_scoped_z_order = it->value;
}
}
void AIPageContentAgent::ContentBuilder::AddAriaRole(
const LayoutObject& object,
mojom::blink::AIPageContentAttributes& attributes) {
if (!actionable_mode()) {
return;
}
auto* element = DynamicTo<Element>(object.GetNode());
if (!element) {
attributes.aria_role = ax::mojom::blink::Role::kUnknown;
return;
}
auto aria_role = AXObject::AriaAttribute(*element, html_names::kRoleAttr);
if (aria_role.empty()) {
attributes.aria_role = ax::mojom::blink::Role::kUnknown;
return;
}
attributes.aria_role = AXObject::FirstValidRoleInRoleString(aria_role);
}
void AIPageContentAgent::ContentBuilder::AddNodeInteractionInfo(
const LayoutObject& object,
mojom::blink::AIPageContentAttributes& attributes,
bool is_aria_disabled) const {
// The node is not hit-testable which also means no interaction is supported.
const ComputedStyle& style = *object.Style();
if (style.UsedPointerEvents() == EPointerEvents::kNone) {
return;
}
const auto* node = object.GetNode();
if (!node) {
return;
}
// Nodes which are not interactive can still consume events if they are
// hit-testable.
auto node_interaction_info =
mojom::blink::AIPageContentNodeInteractionInfo::New();
AddInteractionInfoForHitTesting(node, *node_interaction_info);
auto* form_control_element = DynamicTo<HTMLFormControlElement>(node);
const bool disabled =
(form_control_element && form_control_element->IsActuallyDisabled()) ||
is_aria_disabled;
if (disabled) {
if (node_interaction_info->document_scoped_z_order) {
attributes.node_interaction_info = std::move(node_interaction_info);
// `is_disabled` is only set for nodes with `document_scoped_z_order`.
// This implies offscreen nodes will not be marked as disabled.
attributes.node_interaction_info->is_disabled = true;
}
return;
}
ComputeScrollerInfo(object, *node_interaction_info);
// If experimental data is disabled, only scrollable nodes are included.
if (!actionable_mode()) {
if (node_interaction_info->scroller_info) {
attributes.node_interaction_info = std::move(node_interaction_info);
}
return;
}
if (auto* element = DynamicTo<Element>(object.GetNode())) {
AddClickabilityReasons(*element, *attributes.aria_role,
*node_interaction_info);
// TODO(khushalsagar): Remove is_clickability.
node_interaction_info->is_clickable =
!node_interaction_info->clickability_reasons.empty();
node_interaction_info->is_focusable =
element->IsFocusable(Element::UpdateBehavior::kAssertNoLayoutUpdates);
}
const bool needs_interaction_info =
node_interaction_info->scroller_info ||
node_interaction_info->is_focusable ||
node_interaction_info->document_scoped_z_order ||
!node_interaction_info->clickability_reasons.empty();
if (!needs_interaction_info) {
return;
}
attributes.node_interaction_info = std::move(node_interaction_info);
}
AIPageContentAgent::ContentBuilder::RecursionData::RecursionData(
const ComputedStyle& document_style)
: document_style(document_style) {}
} // namespace blink