///|
/// JavaScript API for Crater layout engine
/// Provides browser-friendly functions for HTML rendering
///|
/// Render HTML to layout tree (text representation)
pub fn renderHtml(html : String, width : Int, height : Int) -> String {
let ctx : @renderer.RenderContext = {
viewport_width: width.to_double(),
viewport_height: height.to_double(),
root_font_size: 16.0,
color_scheme: @media.ColorScheme::Light,
}
let layout = @renderer.render(html, ctx)
let buf = StringBuilder::new()
format_layout(layout, buf, 0)
buf.to_string()
}
///|
fn format_layout(
layout : @layout_types.Layout,
buf : StringBuilder,
indent : Int,
) -> Unit {
let mut i = 0
while i < indent {
buf.write_string(" ")
i = i + 1
}
buf.write_string(layout.id)
buf.write_string(" (")
buf.write_string(layout.x.to_string())
buf.write_string(", ")
buf.write_string(layout.y.to_string())
buf.write_string(") ")
buf.write_string(layout.width.to_string())
buf.write_string("x")
buf.write_string(layout.height.to_string())
buf.write_string("\n")
for child in layout.children {
format_layout(child, buf, indent + 1)
}
}
///|
/// Render HTML to JSON layout tree
pub fn renderHtmlToJson(html : String, width : Int, height : Int) -> String {
let ctx : @renderer.RenderContext = {
viewport_width: width.to_double(),
viewport_height: height.to_double(),
root_font_size: 16.0,
color_scheme: @media.ColorScheme::Light,
}
let layout = @renderer.render(html, ctx)
@renderer.layout_to_json(layout)
}
///|
/// Render HTML to paint node tree (JSON format with colors)
pub fn renderHtmlToPaintTree(
html : String,
width : Int,
height : Int,
) -> String {
let ctx : @renderer.RenderContext = {
viewport_width: width.to_double(),
viewport_height: height.to_double(),
root_font_size: 16.0,
color_scheme: @media.ColorScheme::Light,
}
let node = @renderer.render_to_node(html, ctx)
let layout = @renderer.render(html, ctx)
let paint_tree = @paint.from_node_and_layout(node, layout)
paint_node_to_json(paint_tree)
}
///|
fn paint_node_to_json(node : @paint.PaintNode) -> String {
let buf = StringBuilder::new()
write_paint_node(node, buf)
buf.to_string()
}
///|
fn write_paint_node(node : @paint.PaintNode, buf : StringBuilder) -> Unit {
buf.write_string("{\"id\":\"")
buf.write_string(escape_json_string(node.id))
buf.write_string("\",\"x\":")
buf.write_string(node.x.to_string())
buf.write_string(",\"y\":")
buf.write_string(node.y.to_string())
buf.write_string(",\"width\":")
buf.write_string(node.width.to_string())
buf.write_string(",\"height\":")
buf.write_string(node.height.to_string())
buf.write_string(",\"backgroundColor\":\"")
buf.write_string(color_to_css(node.paint.background_color))
buf.write_string("\",\"color\":\"")
buf.write_string(color_to_css(node.paint.color))
buf.write_string("\",\"opacity\":")
buf.write_string(node.paint.opacity.to_string())
// Text content
match node.text {
Some(t) => {
buf.write_string(",\"text\":\"")
buf.write_string(escape_json_string(t))
buf.write_string("\"")
}
None => ()
}
// Children
buf.write_string(",\"children\":[")
let mut first = true
for child in node.children {
if !first {
buf.write_string(",")
}
first = false
write_paint_node(child, buf)
}
buf.write_string("]}")
}
///|
fn color_to_css(color : @types.Color) -> String {
let buf = StringBuilder::new()
buf.write_string("rgba(")
buf.write_string(color.r.to_string())
buf.write_string(",")
buf.write_string(color.g.to_string())
buf.write_string(",")
buf.write_string(color.b.to_string())
buf.write_string(",")
// alpha is already 0.0-1.0, round to 2 decimal places
let alpha = (color.a * 100.0).to_int().to_double() / 100.0
buf.write_string(alpha.to_string())
buf.write_string(")")
buf.to_string()
}
///|
fn escape_json_string(s : String) -> String {
let buf = StringBuilder::new()
for c in s {
match c {
'"' => buf.write_string("\\\"")
'\\' => buf.write_string("\\\\")
'\n' => buf.write_string("\\n")
'\r' => buf.write_string("\\r")
'\t' => buf.write_string("\\t")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
/// Render HTML to Sixel graphics string
pub fn renderHtmlToSixel(html : String, width : Int, height : Int) -> String {
@renderer_terminal.render_to_sixel(html, width, height)
}
///|
/// Render HTML to Sixel with actual CSS colors
pub fn renderHtmlToSixelWithStyles(
html : String,
width : Int,
height : Int,
) -> String {
@renderer_terminal.render_to_sixel_with_styles(html, width, height)
}
// =============================================================================
// Incremental Layout API
// =============================================================================
///|
/// Global layout tree for incremental updates
let global_tree : Ref[@tree.LayoutTree?] = { val: None }
///|
/// Global cache stats for performance tracking
let global_stats : Ref[@tree.CacheStats?] = { val: None }
///|
/// Create a new layout tree from HTML
/// Returns tree ID (always 0 for now, single tree)
pub fn createTree(html : String, width : Int, height : Int) -> Int {
let doc = @html.parse_document(html)
let tree = @layout_html_tree.layout_tree_from_html_document(
doc,
width.to_double(),
height.to_double(),
)
global_tree.val = Some(tree)
global_stats.val = Some(@tree.CacheStats::new())
0
}
///|
/// Compute layout incrementally (uses cache when possible)
/// Returns JSON layout tree
pub fn computeIncremental() -> String {
match global_tree.val {
Some(tree) => {
let stats = match global_stats.val {
Some(s) => s
None => {
let s = @tree.CacheStats::new()
global_stats.val = Some(s)
s
}
}
let layout = tree.compute_with_stats(stats)
@renderer.layout_to_json(layout)
}
None => "{\"error\":\"No tree created\"}"
}
}
///|
/// Compute full layout (ignores cache)
/// Returns JSON layout tree
pub fn computeFull() -> String {
match global_tree.val {
Some(tree) => {
let layout = tree.compute_full()
@renderer.layout_to_json(layout)
}
None => "{\"error\":\"No tree created\"}"
}
}
///|
/// Mark a node as dirty by ID
/// Returns true if node was found and marked
pub fn markDirty(node_id : String) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
node.mark_dirty()
true
}
None => false
}
None => false
}
}
///|
/// Update node style property
/// Returns true if successful
pub fn updateStyle(node_id : String, css : String) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
let ctx = @computed.ComputeContext::new()
let style = @computed.compute_inline(css, ctx)
// Apply style changes
node.style.width = style.width
node.style.height = style.height
node.style.margin = style.margin
node.style.padding = style.padding
node.style.display = style.display
node.style.flex_direction = style.flex_direction
node.style.flex_grow = style.flex_grow
node.style.flex_shrink = style.flex_shrink
node.style.justify_content = style.justify_content
node.style.align_items = style.align_items
node.style.row_gap = style.row_gap
node.style.column_gap = style.column_gap
node.mark_dirty()
true
}
None => false
}
None => false
}
}
///|
/// Resize viewport
pub fn resizeViewport(width : Int, height : Int) -> Unit {
match global_tree.val {
Some(tree) => tree.resize_viewport(width.to_double(), height.to_double())
None => ()
}
}
///|
/// Get cache statistics as JSON
pub fn getCacheStats() -> String {
match global_stats.val {
Some(stats) => {
let buf = StringBuilder::new()
buf.write_string("{\"hits\":")
buf.write_string(stats.cache_hits.to_string())
buf.write_string(",\"misses\":")
buf.write_string(stats.cache_misses.to_string())
buf.write_string(",\"nodesComputed\":")
buf.write_string(stats.nodes_computed.to_string())
buf.write_string(",\"hitRate\":")
buf.write_string(stats.hit_rate().to_string())
buf.write_string("}")
buf.to_string()
}
None => "{\"hits\":0,\"misses\":0,\"nodesComputed\":0,\"hitRate\":0}"
}
}
///|
/// Reset cache statistics
pub fn resetCacheStats() -> Unit {
global_stats.val = Some(@tree.CacheStats::new())
}
///|
/// Check if tree needs layout recomputation
pub fn needsLayout() -> Bool {
match global_tree.val {
Some(tree) => tree.needs_layout()
None => false
}
}
///|
/// Destroy the current tree
pub fn destroyTree() -> Unit {
global_tree.val = None
global_stats.val = None
}
// =============================================================================
// Yoga-compatible Node API
// =============================================================================
///|
/// Create a new node with optional ID
/// Returns the node's UID
pub fn createNode(id : String) -> Int {
let node = @tree.LayoutNode::create_with_id(id)
node.uid
}
///|
/// Add a child node to parent (appends to end)
/// Returns true if successful
pub fn addChild(parent_id : String, child_id : String) -> Bool {
match global_tree.val {
Some(tree) =>
match (tree.find_node_by_id(parent_id), tree.find_node_by_id(child_id)) {
(Some(parent), Some(child)) => {
ignore(parent.add_child(child))
true
}
_ => false
}
None => false
}
}
///|
/// Insert child at specific index
pub fn insertChild(parent_id : String, child_id : String, index : Int) -> Bool {
match global_tree.val {
Some(tree) =>
match (tree.find_node_by_id(parent_id), tree.find_node_by_id(child_id)) {
(Some(parent), Some(child)) => {
ignore(parent.insert_child(child, index))
true
}
_ => false
}
None => false
}
}
///|
/// Remove child at index
pub fn removeChild(parent_id : String, index : Int) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(parent_id) {
Some(parent) =>
match parent.remove_child_at(index) {
Some(_) => true
None => false
}
None => false
}
None => false
}
}
///|
/// Get child count
pub fn getChildCount(node_id : String) -> Int {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => node.get_child_count()
None => 0
}
None => 0
}
}
// =============================================================================
// Yoga-compatible Style Setters
// =============================================================================
///|
/// Set width in pixels
pub fn setWidth(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_width(value))
true
}
None => false
}
None => false
}
}
///|
/// Set width as percentage (0-100)
pub fn setWidthPercent(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_width_percent(value / 100.0))
true
}
None => false
}
None => false
}
}
///|
/// Set width to auto
pub fn setWidthAuto(node_id : String) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_width_auto())
true
}
None => false
}
None => false
}
}
///|
/// Set height in pixels
pub fn setHeight(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_height(value))
true
}
None => false
}
None => false
}
}
///|
/// Set height as percentage (0-100)
pub fn setHeightPercent(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_height_percent(value / 100.0))
true
}
None => false
}
None => false
}
}
///|
/// Set height to auto
pub fn setHeightAuto(node_id : String) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_height_auto())
true
}
None => false
}
None => false
}
}
///|
/// Set flex grow
pub fn setFlexGrow(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_flex_grow(value))
true
}
None => false
}
None => false
}
}
///|
/// Set flex shrink
pub fn setFlexShrink(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_flex_shrink(value))
true
}
None => false
}
None => false
}
}
///|
/// Set flex basis in pixels
pub fn setFlexBasis(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_flex_basis(value))
true
}
None => false
}
None => false
}
}
///|
/// Set flex direction: 0=row, 1=row-reverse, 2=column, 3=column-reverse
pub fn setFlexDirection(node_id : String, value : Int) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
let dir : @types.FlexDirection = match value {
0 => Row
1 => RowReverse
2 => Column
3 => ColumnReverse
_ => Row
}
ignore(node.set_flex_direction(dir))
true
}
None => false
}
None => false
}
}
///|
/// Set flex wrap: 0=no-wrap, 1=wrap, 2=wrap-reverse
pub fn setFlexWrap(node_id : String, value : Int) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
let wrap : @types.FlexWrap = match value {
0 => NoWrap
1 => Wrap
2 => WrapReverse
_ => NoWrap
}
ignore(node.set_flex_wrap(wrap))
true
}
None => false
}
None => false
}
}
///|
/// Set justify content: 0=start, 1=end, 2=center, 3=space-between, 4=space-around, 5=space-evenly
pub fn setJustifyContent(node_id : String, value : Int) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
let align : @types.Alignment = match value {
0 => FlexStart
1 => FlexEnd
2 => Center
3 => SpaceBetween
4 => SpaceAround
5 => SpaceEvenly
_ => FlexStart
}
ignore(node.set_justify_content(align))
true
}
None => false
}
None => false
}
}
///|
/// Set align items: 0=start, 1=end, 2=center, 3=stretch, 4=baseline
pub fn setAlignItems(node_id : String, value : Int) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
let align : @types.Alignment = match value {
0 => FlexStart
1 => FlexEnd
2 => Center
3 => Stretch
4 => Baseline
_ => Stretch
}
ignore(node.set_align_items(align))
true
}
None => false
}
None => false
}
}
///|
/// Set display: 0=flex, 1=none, 2=block, 3=grid
pub fn setDisplay(node_id : String, value : Int) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
let display : @types.Display = match value {
0 => Flex
1 => None
2 => Block
3 => Grid
_ => Flex
}
ignore(node.set_display(display))
true
}
None => false
}
None => false
}
}
///|
/// Set margin on all sides
pub fn setMargin(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_margin(value))
true
}
None => false
}
None => false
}
}
///|
/// Set padding on all sides
pub fn setPadding(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_padding(value))
true
}
None => false
}
None => false
}
}
///|
/// Set gap (row and column)
pub fn setGap(node_id : String, value : Double) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
ignore(node.set_gap(value))
true
}
None => false
}
None => false
}
}
// =============================================================================
// Yoga-compatible Layout Getters
// =============================================================================
///|
/// Get computed X position
pub fn getComputedLeft(node_id : String) -> Double {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => node.get_layout_x()
None => 0.0
}
None => 0.0
}
}
///|
/// Get computed Y position
pub fn getComputedTop(node_id : String) -> Double {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => node.get_layout_y()
None => 0.0
}
None => 0.0
}
}
///|
/// Get computed width
pub fn getComputedWidth(node_id : String) -> Double {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => node.get_layout_width()
None => 0.0
}
None => 0.0
}
}
///|
/// Get computed height
pub fn getComputedHeight(node_id : String) -> Double {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => node.get_layout_height()
None => 0.0
}
None => 0.0
}
}
///|
/// Check if node has new layout
pub fn hasNewLayout(node_id : String) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => node.get_has_new_layout()
None => false
}
None => false
}
}
///|
/// Mark layout as seen
pub fn markLayoutSeen(node_id : String) -> Bool {
match global_tree.val {
Some(tree) =>
match tree.find_node_by_id(node_id) {
Some(node) => {
node.mark_layout_seen()
true
}
None => false
}
None => false
}
}
///|
/// Calculate layout (Yoga-compatible name)
pub fn calculateLayout(width : Double, height : Double) -> String {
match global_tree.val {
Some(tree) => {
let layout = tree.calculate_layout(width, height)
@renderer.layout_to_json(layout)
}
None => "{\"error\":\"No tree created\"}"
}
}
// =============================================================================
// Accessibility API
// =============================================================================
///|
/// Get ARIA snapshot in YAML format (Playwright-compatible)
pub fn getAriaSnapshot(html : String) -> String {
let doc = @html.parse_document(html)
let tree = @aom.build_accessibility_tree(doc)
tree.to_aria_snapshot()
}
///|
/// Get ARIA snapshot in JSON format
pub fn getAriaSnapshotJson(html : String) -> String {
let doc = @html.parse_document(html)
let tree = @aom.build_accessibility_tree(doc)
tree.to_aria_json()
}
///|
/// Get full accessibility tree as JSON
pub fn getAccessibilityTree(html : String) -> String {
let doc = @html.parse_document(html)
let tree = @aom.build_accessibility_tree(doc)
accessibility_tree_to_json(tree)
}
///|
fn accessibility_tree_to_json(tree : @aom.AccessibilityTree) -> String {
let buf = StringBuilder::new()
write_accessibility_node(tree.root, buf, 0)
buf.to_string()
}
///|
fn write_accessibility_node(
node : @aom.AccessibilityNode,
buf : StringBuilder,
indent : Int,
) -> Unit {
write_json_indent(buf, indent)
buf.write_string("{\n")
// id
write_json_indent(buf, indent + 1)
buf.write_string("\"id\": \"")
buf.write_string(escape_json_string(node.id))
buf.write_string("\",\n")
// role
write_json_indent(buf, indent + 1)
buf.write_string("\"role\": \"")
buf.write_string(node.role.to_string())
buf.write_string("\"")
// name
match node.name {
Some(name) => {
buf.write_string(",\n")
write_json_indent(buf, indent + 1)
buf.write_string("\"name\": \"")
buf.write_string(escape_json_string(name))
buf.write_string("\"")
}
None => ()
}
// description
match node.description {
Some(desc) => {
buf.write_string(",\n")
write_json_indent(buf, indent + 1)
buf.write_string("\"description\": \"")
buf.write_string(escape_json_string(desc))
buf.write_string("\"")
}
None => ()
}
// level
match node.level {
Some(level) => {
buf.write_string(",\n")
write_json_indent(buf, indent + 1)
buf.write_string("\"level\": ")
buf.write_string(level.to_string())
}
None => ()
}
// tag_name
match node.tag_name {
Some(tag) => {
buf.write_string(",\n")
write_json_indent(buf, indent + 1)
buf.write_string("\"tag_name\": \"")
buf.write_string(escape_json_string(tag))
buf.write_string("\"")
}
None => ()
}
// focusable
if node.focusable {
buf.write_string(",\n")
write_json_indent(buf, indent + 1)
buf.write_string("\"focusable\": true")
}
// states
if !node.states.is_empty() {
buf.write_string(",\n")
write_json_indent(buf, indent + 1)
buf.write_string("\"states\": [")
for i, state in node.states {
if i > 0 {
buf.write_string(", ")
}
buf.write_string("\"")
buf.write_string(state.to_string())
buf.write_string("\"")
}
buf.write_string("]")
}
// children
if !node.children.is_empty() {
buf.write_string(",\n")
write_json_indent(buf, indent + 1)
buf.write_string("\"children\": [\n")
for i, child in node.children {
if i > 0 {
buf.write_string(",\n")
}
write_accessibility_node(child, buf, indent + 2)
}
buf.write_string("\n")
write_json_indent(buf, indent + 1)
buf.write_string("]")
}
buf.write_string("\n")
write_json_indent(buf, indent)
buf.write_string("}")
}
///|
fn write_json_indent(buf : StringBuilder, level : Int) -> Unit {
for _ in 0.. FlatLayoutResult {
let nodes : Array[FlatLayoutNode] = []
flatten_layout_rec(layout, "", 0, nodes)
{ nodes, root_id: layout.id }
}
///|
fn flatten_layout_rec(
layout : @layout_types.Layout,
parent_id : String,
index : Int,
nodes : Array[FlatLayoutNode],
) -> Unit {
// Calculate content box (after padding and border)
let content_x = layout.x + layout.padding.left + layout.border.left
let content_y = layout.y + layout.padding.top + layout.border.top
let content_width = layout.width -
layout.padding.left -
layout.padding.right -
layout.border.left -
layout.border.right
let content_height = layout.height -
layout.padding.top -
layout.padding.bottom -
layout.border.top -
layout.border.bottom
let node : FlatLayoutNode = {
id: layout.id,
parent_id,
index,
x: layout.x,
y: layout.y,
width: layout.width,
height: layout.height,
content_x,
content_y,
content_width: if content_width > 0.0 {
content_width
} else {
0.0
},
content_height: if content_height > 0.0 {
content_height
} else {
0.0
},
text: match layout.text {
Some(t) => t
None => ""
},
}
nodes.push(node)
// Recursively process children
for i, child in layout.children {
flatten_layout_rec(child, layout.id, i, nodes)
}
}
// =============================================================================
// Core API Functions
// =============================================================================
///|
/// Compute layout from HTML (returns structured FlatLayoutResult)
pub fn computeLayout(
html : String,
width : Int,
height : Int,
) -> FlatLayoutResult {
let ctx : @renderer.RenderContext = {
viewport_width: width.to_double(),
viewport_height: height.to_double(),
root_font_size: 16.0,
color_scheme: @media.ColorScheme::Light,
}
let layout = @renderer.render(html, ctx)
flatten_layout(layout)
}
///|
/// Compute layout from HTML (returns JSON string)
pub fn computeLayoutJson(html : String, width : Int, height : Int) -> String {
renderHtmlToJson(html, width, height)
}
// =============================================================================
// Incremental API - Structured return versions
// =============================================================================
///|
/// Compute layout incrementally (returns structured FlatLayoutResult)
pub fn computeStructured() -> FlatLayoutResult {
match global_tree.val {
Some(tree) => {
let stats = match global_stats.val {
Some(s) => s
None => {
let s = @tree.CacheStats::new()
global_stats.val = Some(s)
s
}
}
let layout = tree.compute_with_stats(stats)
flatten_layout(layout)
}
None => { nodes: [], root_id: "" }
}
}
///|
/// Compute full layout (returns structured FlatLayoutResult)
pub fn computeFullStructured() -> FlatLayoutResult {
match global_tree.val {
Some(tree) => {
let layout = tree.compute_full()
flatten_layout(layout)
}
None => { nodes: [], root_id: "" }
}
}
// =============================================================================
// Yoga API - Structured return version
// =============================================================================
///|
/// Calculate layout (returns structured FlatLayoutResult)
pub fn calculateLayoutStructured(
width : Double,
height : Double,
) -> FlatLayoutResult {
match global_tree.val {
Some(tree) => {
let layout = tree.calculate_layout(width, height)
flatten_layout(layout)
}
None => { nodes: [], root_id: "" }
}
}
// =============================================================================
// Arc90 Content Extraction API
// =============================================================================
///|
/// Extract main content from HTML using Arc90 algorithm
/// Returns JSON with extracted text and metadata
pub fn extractMainContent(html : String) -> String {
let doc = @html.parse_document(html)
let tree = @aom.build_accessibility_tree(doc)
let result = @arc90.extract_content(tree, @arc90.ExtractConfig::default())
let fallback_text = if result.main_content is None &&
result.content_blocks.length() == 0 {
Some(strip_html_to_text(html))
} else {
None
}
extraction_result_to_json_with_fallback(result, fallback_text)
}
///|
/// Extract main content with layout calculation for better accuracy
/// Uses visual position and size information for scoring
pub fn extractMainContentWithLayout(
html : String,
width : Int,
height : Int,
) -> String {
let ctx : @renderer.RenderContext = {
viewport_width: width.to_double(),
viewport_height: height.to_double(),
root_font_size: 16.0,
color_scheme: @media.ColorScheme::Light,
}
let doc = @html.parse_document(html)
let layout = @renderer.render_document_with_external_css(doc, ctx, [])
// Build accessibility tree with layout bounds
let tree = @aom.build_accessibility_tree_with_node_layout(doc, layout)
// Configure arc90 with viewport dimensions
let config : @arc90.ExtractConfig = {
..@arc90.ExtractConfig::default(),
viewport_width: width.to_double(),
viewport_height: height.to_double(),
}
let result = @arc90.extract_content(tree, config)
let fallback_text = if result.main_content is None &&
result.content_blocks.length() == 0 {
Some(strip_html_to_text(html))
} else {
None
}
extraction_result_to_json_with_fallback(result, fallback_text)
}
///|
fn extraction_result_to_json_with_fallback(
result : @arc90.ExtractionResult,
fallback_text : String?,
) -> String {
let buf = StringBuilder::new()
buf.write_string("{\n")
// Main content text
buf.write_string(" \"mainContent\": ")
match (result.main_content, fallback_text) {
(Some(node), _) => {
let text = collect_all_text(node)
buf.write_string("\"")
buf.write_string(escape_json_string(text))
buf.write_string("\"")
}
(None, Some(text)) => {
buf.write_string("\"")
buf.write_string(escape_json_string(text))
buf.write_string("\"")
}
(None, None) => buf.write_string("null")
}
buf.write_string(",\n")
// Content blocks count
buf.write_string(" \"contentBlocksCount\": ")
buf.write_string(result.content_blocks.length().to_string())
buf.write_string(",\n")
// Detected ads count
buf.write_string(" \"detectedAdsCount\": ")
buf.write_string(result.detected_ads.length().to_string())
buf.write_string(",\n")
// Detected navigation count
buf.write_string(" \"detectedNavigationCount\": ")
buf.write_string(result.detected_navigation.length().to_string())
buf.write_string(",\n")
// Top scores
buf.write_string(" \"topScores\": [")
let len = result.content_blocks.length()
let limit = if len < 3 { len } else { 3 }
for i in 0.. 0 {
buf.write_string(", ")
}
let block = result.content_blocks[i]
buf.write_string((block.score * 1000.0).to_int().to_string())
}
buf.write_string("]\n")
buf.write_string("}")
buf.to_string()
}
///|
fn strip_html_to_text(html : String) -> String {
let buf = StringBuilder::new()
let mut i = 0
let len = html.length()
let mut in_script = false
let mut in_style = false
while i < len {
let c = char_at(html, i)
if c == '<' {
let tag_end = find_next_gt(html, i + 1)
if tag_end < 0 {
break
}
let tag = html.unsafe_substring(start=i + 1, end=tag_end)
let (tag_name, is_end) = parse_tag_name(tag)
if tag_name == "script" {
in_script = !is_end
} else if tag_name == "style" {
in_style = !is_end
}
i = tag_end
} else if !in_script && !in_style {
if is_ascii_whitespace(c) {
buf.write_char(' ')
} else {
buf.write_char(c)
}
}
i = i + 1
}
normalize_spaces(buf.to_string())
}
///|
fn find_next_gt(html : String, start : Int) -> Int {
let len = html.length()
for i in start..' {
return i
}
}
-1
}
///|
fn parse_tag_name(tag : String) -> (String, Bool) {
let len = tag.length()
let mut i = 0
while i < len && is_ascii_whitespace(char_at(tag, i)) {
i = i + 1
}
let mut is_end = false
if i < len && char_at(tag, i) == '/' {
is_end = true
i = i + 1
}
while i < len && is_ascii_whitespace(char_at(tag, i)) {
i = i + 1
}
let name_buf = StringBuilder::new()
while i < len {
let c = char_at(tag, i)
if is_ascii_whitespace(c) || c == '/' {
break
}
name_buf.write_char(c)
i = i + 1
}
(name_buf.to_string().to_lower(), is_end)
}
///|
fn normalize_spaces(text : String) -> String {
let buf = StringBuilder::new()
let mut last_space = true
for c in text {
if is_ascii_whitespace(c) {
if !last_space {
buf.write_char(' ')
last_space = true
}
} else {
buf.write_char(c)
last_space = false
}
}
buf.to_string().trim().to_owned()
}
///|
fn is_ascii_whitespace(c : Char) -> Bool {
c == ' ' ||
c == '\n' ||
c == '\t' ||
c == '\r' ||
c == Int::unsafe_to_char(12)
}
///|
fn char_at(text : String, index : Int) -> Char {
text[index].to_int().unsafe_to_char()
}
///|
fn collect_all_text(node : @aom.AccessibilityNode) -> String {
// If node has aggregated text, use it
match node.text {
Some(text) => return text
None => ()
}
// Check if this is an article/main node - if so, collect all text from it
// (article/main content is always considered main content, including its header)
let is_article_root = match node.role {
@aom.Article | @aom.Main => true
_ =>
match node.tag_name {
Some(t) => t == "article" || t == "main"
None => false
}
}
// Otherwise collect from name and children, excluding navigation elements
let buf = StringBuilder::new()
if is_article_root {
// For article/main, collect all text without navigation filtering
collect_text_simple(node, buf)
} else {
collect_text_with_context(node, buf, false)
}
buf.to_string()
}
///|
/// Text collection for article/main content - skip footer but keep header (article title)
fn collect_text_simple(
node : @aom.AccessibilityNode,
buf : StringBuilder,
) -> Unit {
// Skip invisible nodes
if !node.visible {
return
}
// Skip footer elements (related articles, comments, etc.)
// but keep header (article title, byline, date)
match node.tag_name {
Some(tag) => if tag == "footer" || tag == "aside" { return }
None => ()
}
// Also skip by role
match node.role {
@aom.ContentInfo | @aom.Complementary => return
_ => ()
}
match node.name {
Some(name) if !name.is_empty() =>
// Skip text that looks like JavaScript code
if !looks_like_code(name) {
if buf.to_string().length() > 0 {
buf.write_string(" ")
}
buf.write_string(name)
}
_ => ()
}
for child in node.children {
collect_text_simple(child, buf)
}
}
///|
/// Check if a role represents navigation/boilerplate content
fn is_navigation_role(role : @aom.Role) -> Bool {
match role {
@aom.Navigation | @aom.Banner | @aom.ContentInfo | @aom.Complementary =>
true
_ => false
}
}
///|
/// Check if tag represents navigation/boilerplate content
fn is_boilerplate_tag(tag : String?) -> Bool {
match tag {
Some(t) =>
match t {
"header" | "footer" | "nav" | "aside" => true
_ => false
}
None => false
}
}
///|
/// Check if text looks like JavaScript code
fn looks_like_code(text : String) -> Bool {
// Check for common JS patterns
text.contains("function(") ||
text.contains("function (") ||
text.contains("document.") ||
text.contains("window.") ||
text.contains("var ") ||
text.contains("const ") ||
text.contains("let ") ||
text.contains("addEventListener") ||
text.contains("setTimeout") ||
text.contains("setInterval") ||
text.contains("querySelector")
}
///|
/// Collect text with context tracking for ancestor navigation elements
fn collect_text_with_context(
node : @aom.AccessibilityNode,
buf : StringBuilder,
in_navigation : Bool,
) -> Unit {
// Check if we're entering a navigation/boilerplate section
let is_nav = is_navigation_role(node.role) ||
is_boilerplate_tag(node.tag_name)
let in_nav = in_navigation || is_nav
// Skip all text inside navigation sections
if in_nav {
// Still recurse to find any article/main sections that might be nested
for child in node.children {
// If child is article or main, reset navigation context
let child_is_content = match child.role {
@aom.Article | @aom.Main => true
_ => false
}
let child_tag_is_content = match child.tag_name {
Some(t) => t == "article" || t == "main"
None => false
}
if child_is_content || child_tag_is_content {
collect_text_with_context(child, buf, false)
} else {
collect_text_with_context(child, buf, true)
}
}
return
}
// Skip invisible nodes
if !node.visible {
return
}
match node.name {
Some(name) if !name.is_empty() =>
// Skip text that looks like JavaScript code
if !looks_like_code(name) {
if buf.to_string().length() > 0 {
buf.write_string(" ")
}
buf.write_string(name)
}
_ => ()
}
for child in node.children {
collect_text_with_context(child, buf, false)
}
}