///|
/// Sixel Renderer for terminal visualization
/// Sixel is a DEC graphics format for displaying images in terminals
///|
/// RGB Color (0-255 range)
pub(all) struct Color {
r : Int
g : Int
b : Int
}
///|
/// Predefined colors for visualization
pub fn Color::transparent() -> Color {
{ r: 0, g: 0, b: 0 }
}
///|
pub fn Color::blue() -> Color {
{ r: 66, g: 133, b: 244 }
}
///|
pub fn Color::green() -> Color {
{ r: 52, g: 168, b: 83 }
}
///|
pub fn Color::yellow() -> Color {
{ r: 251, g: 188, b: 4 }
}
///|
pub fn Color::red() -> Color {
{ r: 234, g: 67, b: 53 }
}
///|
pub fn Color::gray() -> Color {
{ r: 154, g: 160, b: 166 }
}
///|
pub fn Color::white() -> Color {
{ r: 255, g: 255, b: 255 }
}
///|
pub fn Color::black() -> Color {
{ r: 0, g: 0, b: 0 }
}
///|
pub(all) struct RasterImage {
width : Int
height : Int
rgba : Array[Int]
}
///|
pub(all) enum ResolvedImage {
Raster(RasterImage)
Svg(String)
}
///|
pub(all) struct ImageProvider {
resolve : (String) -> ResolvedImage?
}
///|
let image_provider_override : Ref[ImageProvider?] = { val: None }
///|
pub fn set_image_provider(provider : ImageProvider) -> Unit {
image_provider_override.val = Some(provider)
}
///|
pub fn clear_image_provider() -> Unit {
image_provider_override.val = None
}
///|
/// Get color by depth level
pub fn get_depth_color(depth : Int) -> Color {
let colors = [
Color::blue(),
Color::green(),
Color::yellow(),
Color::red(),
Color::gray(),
]
colors[depth % colors.length()]
}
///|
/// Blend color with alpha (0-255)
pub fn Color::blend(self : Color, bg : Color, alpha : Int) -> Color {
let a = alpha.to_double() / 255.0
let inv_a = 1.0 - a
{
r: (self.r.to_double() * a + bg.r.to_double() * inv_a).to_int(),
g: (self.g.to_double() * a + bg.g.to_double() * inv_a).to_int(),
b: (self.b.to_double() * a + bg.b.to_double() * inv_a).to_int(),
}
}
///|
let sixel_bit_masks : Array[Int] = [1, 2, 4, 8, 16, 32]
///|
///|
#cfg(not(target="js"))
let base64_chars : String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
///|
#cfg(not(target="js"))
fn write_base64_quad(
buf : StringBuilder,
b0 : Int,
b1 : Int,
b2 : Int,
padding : Int,
) -> Unit {
let c0 = (b0 >> 2) & 0x3F
let c1 = ((b0 << 4) | (b1 >> 4)) & 0x3F
let c2 = ((b1 << 2) | (b2 >> 6)) & 0x3F
let c3 = b2 & 0x3F
buf.write_char(base64_chars[c0].unsafe_to_char())
buf.write_char(base64_chars[c1].unsafe_to_char())
if padding >= 2 {
buf.write_char('=')
} else {
buf.write_char(base64_chars[c2].unsafe_to_char())
}
if padding >= 1 {
buf.write_char('=')
} else {
buf.write_char(base64_chars[c3].unsafe_to_char())
}
}
///|
#cfg(not(target="js"))
fn encode_bytes_base64(bytes : Array[Int]) -> String {
let out = StringBuilder::new()
let mut i = 0
while i < bytes.length() {
let b0 = bytes[i]
let remaining = bytes.length() - i
if remaining >= 3 {
write_base64_quad(out, b0, bytes[i + 1], bytes[i + 2], 0)
i = i + 3
} else if remaining == 2 {
write_base64_quad(out, b0, bytes[i + 1], 0, 1)
i = i + 2
} else {
write_base64_quad(out, b0, 0, 0, 2)
i = i + 1
}
}
out.to_string()
}
///|
fn write_sixel_repeat_or_char(
buf : StringBuilder,
ch : Char,
repeat_count : Int,
) -> Unit {
if repeat_count >= 4 {
buf.write_string("!")
buf.write_string(repeat_count.to_string())
buf.write_char(ch)
} else {
for _i in 0.. Unit {
if width <= 0 {
return
}
let mut run_char = (63 + band_rows[row_base]).unsafe_to_char()
let mut run_len = 1
for x = 1; x < width; x = x + 1 {
let next_char = (63 + band_rows[row_base + x]).unsafe_to_char()
if next_char == run_char {
run_len = run_len + 1
} else {
write_sixel_repeat_or_char(buf, run_char, run_len)
run_char = next_char
run_len = 1
}
}
write_sixel_repeat_or_char(buf, run_char, run_len)
}
///|
fn color_percent_string(value : Int) -> String {
(value * 100 / 255).to_string()
}
///|
fn build_sixel_palette_definition(colors : Array[Color]) -> String {
let buf = StringBuilder::new()
for i, color in colors {
buf.write_string("#")
buf.write_string(i.to_string())
buf.write_string(";2;")
buf.write_string(color_percent_string(color.r))
buf.write_string(";")
buf.write_string(color_percent_string(color.g))
buf.write_string(";")
buf.write_string(color_percent_string(color.b))
}
buf.to_string()
}
///|
fn build_sixel_color_headers(count : Int) -> Array[String] {
let headers : Array[String] = []
for i = 0; i < count; i = i + 1 {
headers.push("#" + i.to_string())
}
headers
}
///|
/// Simple framebuffer for rendering
pub struct Framebuffer {
width : Int
height : Int
pixels : Array[Int] // Color index per pixel (0 = transparent)
}
///|
pub fn Framebuffer::new(width : Int, height : Int) -> Framebuffer {
let size = width * height
let pixels : Array[Int] = Array::make(size, 0)
{ width, height, pixels }
}
///|
fn Framebuffer::set_pixel(
self : Framebuffer,
x : Int,
y : Int,
color : Int,
) -> Unit {
if x >= 0 && x < self.width && y >= 0 && y < self.height {
self.pixels[y * self.width + x] = color
}
}
///|
pub fn Framebuffer::get_pixel(self : Framebuffer, x : Int, y : Int) -> Int {
if x >= 0 && x < self.width && y >= 0 && y < self.height {
self.pixels[y * self.width + x]
} else {
0
}
}
///|
/// Draw a filled rectangle
pub fn Framebuffer::fill_rect(
self : Framebuffer,
x : Int,
y : Int,
w : Int,
h : Int,
color : Int,
) -> Unit {
for py = y; py < y + h; py = py + 1 {
for px = x; px < x + w; px = px + 1 {
self.set_pixel(px, py, color)
}
}
}
///|
/// Draw a rectangle outline
pub fn Framebuffer::stroke_rect(
self : Framebuffer,
x : Int,
y : Int,
w : Int,
h : Int,
color : Int,
) -> Unit {
// Top and bottom edges
for px = x; px < x + w; px = px + 1 {
self.set_pixel(px, y, color)
self.set_pixel(px, y + h - 1, color)
}
// Left and right edges
for py = y; py < y + h; py = py + 1 {
self.set_pixel(x, py, color)
self.set_pixel(x + w - 1, py, color)
}
}
///|
/// Draw a filled rectangle with diagonal hatching pattern
/// spacing: pixels between diagonal lines (e.g., 4)
pub fn Framebuffer::fill_rect_hatched(
self : Framebuffer,
x : Int,
y : Int,
w : Int,
h : Int,
bg_color : Int,
line_color : Int,
spacing : Int,
) -> Unit {
for py = y; py < y + h; py = py + 1 {
for px = x; px < x + w; px = px + 1 {
// Diagonal pattern: (x + y) % spacing == 0
if (px + py) % spacing == 0 {
self.set_pixel(px, py, line_color)
} else {
self.set_pixel(px, py, bg_color)
}
}
}
}
///|
/// Check if a character is East Asian Wide (CJK characters that take 2 columns)
fn is_wide_char(c : Char) -> Bool {
let cp = c.to_int()
// CJK Unified Ideographs (U+4E00–U+9FFF)
(cp >= 0x4E00 && cp <= 0x9FFF) ||
// CJK Extension A (U+3400–U+4DBF)
(cp >= 0x3400 && cp <= 0x4DBF) ||
// Hiragana (U+3040–U+309F)
(cp >= 0x3040 && cp <= 0x309F) ||
// Katakana (U+30A0–U+30FF)
(cp >= 0x30A0 && cp <= 0x30FF) ||
// Hangul Syllables (U+AC00–U+D7AF)
(cp >= 0xAC00 && cp <= 0xD7AF) ||
// Fullwidth Forms (U+FF00–U+FFEF)
(cp >= 0xFF00 && cp <= 0xFFEF) ||
// CJK Symbols and Punctuation (U+3000–U+303F)
(cp >= 0x3000 && cp <= 0x303F)
}
///|
/// Draw a single character at the given position using bitmap font
/// Returns the width of the character drawn (in pixels)
pub fn Framebuffer::draw_char(
self : Framebuffer,
x : Int,
y : Int,
c : Char,
color : Int,
scale : Int,
) -> Int {
// Handle CJK: draw outlined rectangle for 2-column width (placeholder)
if is_wide_char(c) {
let char_w = font_width * 2 * scale
let char_h = font_height * scale
// Draw outlined rectangle as CJK placeholder (not filled to avoid black blocks)
self.stroke_rect(x + 1, y + 1, char_w - 2, char_h - 2, color)
return char_w
}
// Handle ASCII with bitmap font
match get_char_bitmap(c) {
Some(bitmap) => {
for row = 0; row < font_height; row = row + 1 {
let row_data = bitmap[row]
for col = 0; col < font_width; col = col + 1 {
// Check if bit is set (MSB = leftmost pixel)
let bit = (row_data >> (7 - col)) & 1
if bit == 1 {
// Draw scaled pixel
for sy = 0; sy < scale; sy = sy + 1 {
for sx = 0; sx < scale; sx = sx + 1 {
self.set_pixel(
x + col * scale + sx,
y + row * scale + sy,
color,
)
}
}
}
}
}
font_width * scale
}
None => {
// Unsupported character: draw small rectangle as placeholder
let char_w = font_width * scale
let char_h = font_height * scale
self.stroke_rect(x + 1, y + 1, char_w - 2, char_h - 2, color)
char_w
}
}
}
///|
/// Draw text string at the given position
/// Handles line wrapping within the given width constraint
/// Returns the height of the text drawn (in pixels)
pub fn Framebuffer::draw_text(
self : Framebuffer,
x : Int,
y : Int,
text : String,
max_width : Int,
color : Int,
scale : Int,
) -> Int {
let char_h = font_height * scale
let mut cur_x = x
let mut cur_y = y
let mut max_y = y + char_h
for c in text {
// Handle newline
if c == '\n' {
cur_x = x
cur_y = cur_y + char_h
max_y = cur_y + char_h
continue
}
// Skip carriage return
if c == '\r' {
continue
}
// Calculate character width
let char_w = if is_wide_char(c) {
font_width * 2 * scale
} else {
font_width * scale
}
// Check for line wrap
if max_width > 0 && cur_x + char_w > x + max_width {
cur_x = x
cur_y = cur_y + char_h
max_y = cur_y + char_h
}
// Draw the character
let drawn_w = self.draw_char(cur_x, cur_y, c, color, scale)
cur_x = cur_x + drawn_w
}
max_y - y
}
///|
/// Draw text with clipping support (bitmap font fallback)
fn draw_text_clipped(
fb : Framebuffer,
x : Int,
y : Int,
text : String,
max_width : Int,
color : Int,
scale : Int,
clip : ClipRect?,
) -> Int {
match clip {
None => fb.draw_text(x, y, text, max_width, color, scale)
Some(_) => {
// For bitmap font, we draw normally but use set_pixel which bounds-checks.
// To clip, we temporarily render to the framebuffer but only write pixels in clip.
let char_h = font_height * scale
let mut cur_x = x
let mut cur_y = y
let mut max_y = y + char_h
for c in text {
if c == '\n' {
cur_x = x
cur_y = cur_y + char_h
max_y = cur_y + char_h
continue
}
if c == '\r' {
continue
}
let char_w = if is_wide_char(c) {
font_width * 2 * scale
} else {
font_width * scale
}
if max_width > 0 && cur_x + char_w > x + max_width {
cur_x = x
cur_y = cur_y + char_h
max_y = cur_y + char_h
}
// Only draw if any part might be visible
if pixel_in_clip(clip, cur_x, cur_y) ||
pixel_in_clip(clip, cur_x + char_w - 1, cur_y + char_h - 1) {
let drawn_w = fb.draw_char(cur_x, cur_y, c, color, scale)
cur_x = cur_x + drawn_w
} else {
let char_w2 = if is_wide_char(c) {
font_width * 2 * scale
} else {
font_width * scale
}
cur_x = cur_x + char_w2
}
}
max_y - y
}
}
}
///|
/// Color palette for Sixel output
priv struct Palette {
colors : Array[Color]
}
///|
fn Palette::new() -> Palette {
{
colors: [
Color::white(),
Color::blue(),
Color::green(),
Color::yellow(),
Color::red(),
Color::gray(),
Color::black(),
],
}
}
///|
/// Extended palette with depth gradients and text colors
fn Palette::extended() -> Palette {
{
colors: [
Color::white(), // 0: background
{ r: 220, g: 235, b: 250 }, // 1: depth 0 - very light blue
{ r: 185, g: 215, b: 240 }, // 2: depth 1 - light blue
{ r: 150, g: 195, b: 230 }, // 3: depth 2 - medium light blue
{ r: 115, g: 175, b: 220 }, // 4: depth 3 - medium blue
{ r: 80, g: 155, b: 210 }, // 5: depth 4 - darker blue
{ r: 45, g: 135, b: 200 }, // 6: depth 5+ - dark blue
{ r: 34, g: 139, b: 34 }, // 7: text color - forest green
{ r: 0, g: 100, b: 0 }, // 8: text border - dark green
Color::black(), // 9: black for borders
],
}
}
///|
/// Convert framebuffer to Sixel string
pub fn to_sixel(fb : Framebuffer) -> String {
let buf = StringBuilder::new()
let palette = Palette::new()
let color_headers = build_sixel_color_headers(palette.colors.length())
// Sixel start sequence: ESC P q
buf.write_string("\u001bPq")
// Define color palette
// Format: #n;2;r;g;b (RGB in 0-100 range)
buf.write_string(build_sixel_palette_definition(palette.colors))
// Process in bands of 6 pixels height
let num_bands = (fb.height + 5) / 6
for band = 0; band < num_bands; band = band + 1 {
let y_start = band * 6
// For each color in palette
for color_idx = 0
color_idx < palette.colors.length()
color_idx = color_idx + 1 {
// Select color
buf.write_string(color_headers[color_idx])
// Encode row
for x = 0; x < fb.width; x = x + 1 {
let mut sixel_value = 0
// Bit masks for each of the 6 pixels in a sixel
let bit_masks = [1, 2, 4, 8, 16, 32]
for bit = 0; bit < 6; bit = bit + 1 {
let y = y_start + bit
if y < fb.height {
let pixel = fb.get_pixel(x, y)
if pixel == color_idx {
sixel_value = sixel_value | bit_masks[bit]
}
}
}
// Sixel character is value + 63 ('?')
let ch = (63 + sixel_value).unsafe_to_char()
buf.write_char(ch)
}
// Carriage return (go back to start of row)
buf.write_char('$')
}
// Line feed (move to next band)
buf.write_char('-')
}
// Sixel end sequence: ESC \
buf.write_string("\u001b\\")
buf.to_string()
}
///|
/// Convert framebuffer to Sixel string with extended palette
pub fn to_sixel_extended(fb : Framebuffer) -> String {
let buf = StringBuilder::new()
let palette = Palette::extended()
let color_headers = build_sixel_color_headers(palette.colors.length())
// Sixel start sequence: ESC P q
buf.write_string("\u001bPq")
// Define color palette
buf.write_string(build_sixel_palette_definition(palette.colors))
// Process in bands of 6 pixels height
let num_bands = (fb.height + 5) / 6
for band = 0; band < num_bands; band = band + 1 {
let y_start = band * 6
for color_idx = 0
color_idx < palette.colors.length()
color_idx = color_idx + 1 {
buf.write_string(color_headers[color_idx])
for x = 0; x < fb.width; x = x + 1 {
let mut sixel_value = 0
let bit_masks = [1, 2, 4, 8, 16, 32]
for bit = 0; bit < 6; bit = bit + 1 {
let y = y_start + bit
if y < fb.height {
let pixel = fb.get_pixel(x, y)
if pixel == color_idx {
sixel_value = sixel_value | bit_masks[bit]
}
}
}
let ch = (63 + sixel_value).unsafe_to_char()
buf.write_char(ch)
}
buf.write_char('$')
}
buf.write_char('-')
}
buf.write_string("\u001b\\")
buf.to_string()
}
///|
/// Render layout tree to Sixel string
pub fn render_layout(
layout : @types.Layout,
width : Int,
height : Int,
) -> String {
render_layout_with_options(layout, width, height, false)
}
///|
/// Render layout tree to Sixel string with options
pub fn render_layout_with_options(
layout : @types.Layout,
width : Int,
height : Int,
show_box_model : Bool,
) -> String {
let fb = Framebuffer::new(width, height)
// Fill with white background
fb.fill_rect(0, 0, width, height, 0)
// Render nodes
if show_box_model {
render_node_with_box_model(fb, layout, 0.0, 0.0, 0, true)
to_sixel(fb)
} else {
render_node_simple(fb, layout, 0.0, 0.0, 0)
to_sixel_extended(fb)
}
}
///|
/// Check if this is a text node
fn is_text_node(id : String) -> Bool {
id == "#text"
}
///|
/// Simple rendering with depth-based gradient colors
/// Extended palette indices:
/// 0: white (background)
/// 1-6: depth gradient (light to dark blue)
/// 7: text fill (forest green)
/// 8: text border (dark green)
/// 9: black (borders)
fn render_node_simple(
fb : Framebuffer,
layout : @types.Layout,
parent_x : Double,
parent_y : Double,
depth : Int,
) -> Unit {
let abs_x = parent_x + layout.x
let abs_y = parent_y + layout.y
let x = abs_x.to_int()
let y = abs_y.to_int()
let w = layout.width.to_int()
let h = layout.height.to_int()
// Skip zero-sized nodes
if w == 0 || h == 0 {
for child in layout.children {
render_node_simple(fb, child, abs_x, abs_y, depth + 1)
}
return
}
if is_text_node(layout.id) {
// Text nodes: render actual text content if available
fb.fill_rect(x, y, w, h, 0) // White background
match layout.text {
Some(text_content) => {
// Draw text using bitmap font (scale 1 = 8x16 pixels per char)
// Use the layout width directly without padding reduction
// since text measurement already accounts for the exact text width
let scale = 1
let _ = fb.draw_text(x, y, text_content, w, 7, scale)
// Draw border
fb.stroke_rect(x, y, w, h, 8) // Dark green border
}
None => {
// Fallback: diagonal hatching for text nodes without content
fb.fill_rect_hatched(x, y, w, h, 0, 7, 3)
fb.stroke_rect(x, y, w, h, 8)
}
}
} else {
// Container nodes: depth-based blue gradient (1-6, clamped)
let color_idx = if depth < 6 { depth + 1 } else { 6 }
fb.fill_rect(x, y, w, h, color_idx)
fb.stroke_rect(x, y, w, h, 9) // Black border
}
// Render children
for child in layout.children {
render_node_simple(fb, child, abs_x, abs_y, depth + 1)
}
}
///|
/// Render node with optional box model visualization
fn render_node_with_box_model(
fb : Framebuffer,
layout : @types.Layout,
parent_x : Double,
parent_y : Double,
depth : Int,
show_box_model : Bool,
) -> Unit {
let abs_x = parent_x + layout.x
let abs_y = parent_y + layout.y
let x = abs_x.to_int()
let y = abs_y.to_int()
let w = layout.width.to_int()
let h = layout.height.to_int()
// Skip zero-sized nodes
if w == 0 || h == 0 {
// Still render children
for child in layout.children {
render_node_with_box_model(
fb,
child,
abs_x,
abs_y,
depth + 1,
show_box_model,
)
}
return
}
// Different rendering for text nodes vs container nodes
if is_text_node(layout.id) {
// Render text with diagonal hatching pattern
fb.fill_rect_hatched(x, y, w, h, 0, 5, 3) // White bg, gray lines, spacing 3
fb.stroke_rect(x, y, w, h, 6) // Black border
// Show box model: margin (orange), padding (green), content (blue)
} else if show_box_model {
let margin_t = layout.margin.top.to_int()
let margin_r = layout.margin.right.to_int()
let margin_b = layout.margin.bottom.to_int()
let margin_l = layout.margin.left.to_int()
let padding_t = layout.padding.top.to_int()
let padding_r = layout.padding.right.to_int()
let padding_b = layout.padding.bottom.to_int()
let padding_l = layout.padding.left.to_int()
let border_t = layout.border.top.to_int()
let border_r = layout.border.right.to_int()
let border_b = layout.border.bottom.to_int()
let border_l = layout.border.left.to_int()
// Draw margin area (orange/yellow) - color 4
// Top margin
if margin_t > 0 {
fb.fill_rect(
x - margin_l,
y - margin_t,
w + margin_l + margin_r,
margin_t,
4,
)
}
// Bottom margin
if margin_b > 0 {
fb.fill_rect(x - margin_l, y + h, w + margin_l + margin_r, margin_b, 4)
}
// Left margin (excluding corners already covered)
if margin_l > 0 {
fb.fill_rect(x - margin_l, y, margin_l, h, 4)
}
// Right margin
if margin_r > 0 {
fb.fill_rect(x + w, y, margin_r, h, 4)
}
// Draw padding area (green) - color 2
// Content area starts after border
let content_x = x + border_l + padding_l
let content_y = y + border_t + padding_t
let content_w = w - border_l - border_r - padding_l - padding_r
let content_h = h - border_t - border_b - padding_t - padding_b
// Top padding
if padding_t > 0 {
fb.fill_rect(
x + border_l,
y + border_t,
w - border_l - border_r,
padding_t,
2,
)
}
// Bottom padding
if padding_b > 0 {
fb.fill_rect(
x + border_l,
y + h - border_b - padding_b,
w - border_l - border_r,
padding_b,
2,
)
}
// Left padding
if padding_l > 0 {
fb.fill_rect(
x + border_l,
y + border_t + padding_t,
padding_l,
content_h,
2,
)
}
// Right padding
if padding_r > 0 {
fb.fill_rect(
x + w - border_r - padding_r,
y + border_t + padding_t,
padding_r,
content_h,
2,
)
}
// Draw content area (blue) - color 1
if content_w > 0 && content_h > 0 {
fb.fill_rect(content_x, content_y, content_w, content_h, 1)
}
// Draw border (black outline)
fb.stroke_rect(x, y, w, h, 6)
} else {
// Simple rendering: just fill with depth-based color
let color = depth % 5 + 1
fb.fill_rect(x, y, w, h, color)
fb.stroke_rect(x, y, w, h, 6)
}
// Render children
for child in layout.children {
render_node_with_box_model(
fb,
child,
abs_x,
abs_y,
depth + 1,
show_box_model,
)
}
}
///|
/// Simple print function that outputs sixel to stdout
pub fn print_layout(layout : @types.Layout, width : Int, height : Int) -> Unit {
let _ = render_layout(layout, width, height)
}
// =============================================================================
// PaintNode-based Rendering with Dynamic Colors
// =============================================================================
///|
/// Dynamic color palette that builds from PaintNode colors
pub struct DynamicPalette {
colors : Array[Color]
color_map : Map[Int, Int] // color hash -> palette index
}
///|
pub fn DynamicPalette::new() -> DynamicPalette {
let colors : Array[Color] = []
// Reserve index 0 for transparent/white background
colors.push(Color::white())
{ colors, color_map: {} }
}
///|
/// Convert types.Color to sixel Color
fn types_color_to_sixel(c : @types.Color) -> Color {
{ r: c.r, g: c.g, b: c.b }
}
///|
fn clamp_byte(v : Int) -> Int {
if v < 0 {
0
} else if v > 255 {
255
} else {
v
}
}
///|
fn effective_alpha_byte(color : @types.Color, opacity : Double) -> Int {
clamp_byte((color.a * opacity * 255.0).to_int())
}
///|
fn palette_color_at(palette : DynamicPalette, idx : Int) -> Color {
if idx >= 0 && idx < palette.colors.length() {
palette.colors[idx]
} else {
Color::white()
}
}
///|
#cfg(target="js")
extern "js" fn framebuffer_to_rgba_base64_js(
pixels : Array[Int],
palette_r : Array[Int],
palette_g : Array[Int],
palette_b : Array[Int],
) -> String =
#|(pixels, pr, pg, pb) => {
#| const n = pixels.length;
#| const buf = new Uint8Array(n * 4);
#| for (let i = 0; i < n; i++) {
#| const idx = pixels[i];
#| const o = i * 4;
#| buf[o] = pr[idx] | 0;
#| buf[o + 1] = pg[idx] | 0;
#| buf[o + 2] = pb[idx] | 0;
#| buf[o + 3] = 255;
#| }
#| if (typeof Buffer === "function") {
#| return Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength).toString("base64");
#| }
#| let s = '';
#| const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
#| const len = buf.length;
#| let i = 0;
#| for (; i + 2 < len; i += 3) {
#| const b0 = buf[i], b1 = buf[i+1], b2 = buf[i+2];
#| s += chars[b0 >> 2] + chars[((b0 & 3) << 4) | (b1 >> 4)] + chars[((b1 & 15) << 2) | (b2 >> 6)] + chars[b2 & 63];
#| }
#| if (i + 1 === len) {
#| const b0 = buf[i];
#| s += chars[b0 >> 2] + chars[(b0 & 3) << 4] + '==';
#| } else if (i + 2 === len) {
#| const b0 = buf[i], b1 = buf[i+1];
#| s += chars[b0 >> 2] + chars[((b0 & 3) << 4) | (b1 >> 4)] + chars[(b1 & 15) << 2] + '=';
#| }
#| return s;
#|}
///|
#cfg(not(target="js"))
fn framebuffer_to_rgba_base64_js(
pixels : Array[Int],
palette_r : Array[Int],
palette_g : Array[Int],
palette_b : Array[Int],
) -> String {
let rgba : Array[Int] = []
for idx in pixels {
rgba.push(palette_r[idx])
rgba.push(palette_g[idx])
rgba.push(palette_b[idx])
rgba.push(255)
}
encode_bytes_base64(rgba)
}
///|
pub fn framebuffer_to_rgba_base64(
fb : Framebuffer,
palette : DynamicPalette,
) -> String {
let palette_r : Array[Int] = []
let palette_g : Array[Int] = []
let palette_b : Array[Int] = []
for color in palette.colors {
palette_r.push(color.r)
palette_g.push(color.g)
palette_b.push(color.b)
}
framebuffer_to_rgba_base64_js(fb.pixels, palette_r, palette_g, palette_b)
}
///|
fn blend_types_color_over_pixel(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
color : @types.Color,
opacity : Double,
) -> Unit {
let alpha = effective_alpha_byte(color, opacity)
if alpha <= 0 {
return
}
let src = types_color_to_sixel(color)
if alpha >= 255 {
let idx = palette.get_or_add(src)
fb.set_pixel(x, y, idx)
return
}
let dst_idx = fb.get_pixel(x, y)
let dst = palette_color_at(palette, dst_idx)
let blended = src.blend(dst, alpha)
let idx = palette.get_or_add(blended)
fb.set_pixel(x, y, idx)
}
///|
fn blend_sixel_color_over_pixel(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
color : Color,
alpha : Int,
) -> Unit {
let a = clamp_byte(alpha)
if a <= 0 {
return
}
if a >= 255 {
let idx = palette.get_or_add(color)
fb.set_pixel(x, y, idx)
return
}
let dst_idx = fb.get_pixel(x, y)
let dst = palette_color_at(palette, dst_idx)
let blended = color.blend(dst, a)
let idx = palette.get_or_add(blended)
fb.set_pixel(x, y, idx)
}
///|
fn fill_rect_with_types_color(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
w : Int,
h : Int,
color : @types.Color,
opacity : Double,
) -> Unit {
let alpha = effective_alpha_byte(color, opacity)
if alpha <= 0 {
return
}
if alpha >= 255 {
let idx = palette.get_or_add(types_color_to_sixel(color))
fb.fill_rect(x, y, w, h, idx)
return
}
for py = y; py < y + h; py = py + 1 {
for px = x; px < x + w; px = px + 1 {
blend_types_color_over_pixel(fb, palette, px, py, color, opacity)
}
}
}
///|
/// Hash a color for map lookup
fn color_hash(c : Color) -> Int {
c.r * 65536 + c.g * 256 + c.b
}
///|
/// Quantize a color channel to reduce palette entries
/// Rounds to nearest multiple of step (e.g., step=4 gives 64 levels per channel)
fn quantize_channel(v : Int, step : Int) -> Int {
let q = (v + step / 2) / step * step
if q > 255 {
255
} else {
q
}
}
///|
/// Get or add a color to the palette, returns palette index
fn DynamicPalette::get_or_add(self : DynamicPalette, c : Color) -> Int {
let hash = color_hash(c)
match self.color_map.get(hash) {
Some(idx) => idx
None => {
let idx = self.colors.length()
if idx < 4096 {
self.colors.push(c)
self.color_map.set(hash, idx)
idx
} else {
// Palette full — quantize and find nearest existing entry
let qc : Color = {
r: quantize_channel(c.r, 8),
g: quantize_channel(c.g, 8),
b: quantize_channel(c.b, 8),
}
let qhash = color_hash(qc)
match self.color_map.get(qhash) {
Some(qi) => qi
None => idx - 1
}
}
}
}
}
///|
/// Collect all colors from PaintNode tree
fn collect_colors(
palette : DynamicPalette,
node : @paint_model.PaintNode,
) -> Unit {
// Add background color if not transparent
if !node.paint.background_color.is_transparent() {
let _ = palette.get_or_add(
types_color_to_sixel(node.paint.background_color),
)
}
for shadow in node.paint.box_shadows {
if !shadow.color.is_transparent() {
let _ = palette.get_or_add(types_color_to_sixel(shadow.color))
}
}
// Add text color
let _ = palette.get_or_add(types_color_to_sixel(node.paint.color))
// Process children
for child in node.children {
collect_colors(palette, child)
}
}
///|
/// Clip rectangle for overflow clipping.
/// None means no clipping (full framebuffer).
pub(all) struct ClipRect {
x : Int
y : Int
w : Int
h : Int
}
///|
/// Intersect two clip rectangles. Returns None if they don't overlap.
fn clip_intersect(a : ClipRect, b : ClipRect) -> ClipRect? {
let x1 = @cmp.maximum(a.x, b.x)
let y1 = @cmp.maximum(a.y, b.y)
let x2 = @cmp.minimum(a.x + a.w, b.x + b.w)
let y2 = @cmp.minimum(a.y + a.h, b.y + b.h)
if x2 > x1 && y2 > y1 {
Some({ x: x1, y: y1, w: x2 - x1, h: y2 - y1 })
} else {
None
}
}
///|
/// Check if a pixel is inside the clip rect
fn pixel_in_clip(clip : ClipRect?, px : Int, py : Int) -> Bool {
match clip {
None => true
Some(c) => px >= c.x && px < c.x + c.w && py >= c.y && py < c.y + c.h
}
}
///|
fn draw_text_decoration_line(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
w : Int,
color : @types.Color,
opacity : Double,
clip : ClipRect?,
) -> Unit {
if y < 0 || y >= fb.height {
return
}
for px = x; px < x + w && px < fb.width; px = px + 1 {
if px >= 0 && pixel_in_clip(clip, px, y) {
blend_types_color_over_pixel(fb, palette, px, y, color, opacity)
}
}
}
///|
fn resolve_text_render_box(
node : @paint_model.PaintNode,
x : Int,
y : Int,
w : Int,
h : Int,
) -> (Int, Int, Int) {
if node.tag == "input" {
let inset = @cmp.maximum(2, (node.paint.font_size * 0.75).round().to_int())
let vertical_offset = @cmp.maximum(
0,
((h.to_double() - node.paint.font_size) / 2.0).round().to_int(),
)
(x + inset, y + vertical_offset, @cmp.maximum(0, w - inset * 2))
} else {
(x, y, w)
}
}
///|
fn resolve_glyph_text_wrap_width(
w : Int,
h : Int,
font_size : Double,
text : String,
) -> Int {
if h.to_double() <= font_size * 1.3 && text.length() <= 72 {
0
} else {
w
}
}
///|
/// Fill a rect clipped to the given clip rectangle
fn fill_rect_clipped(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
w : Int,
h : Int,
color : @types.Color,
opacity : Double,
clip : ClipRect?,
) -> Unit {
match clip {
None => fill_rect_with_types_color(fb, palette, x, y, w, h, color, opacity)
Some(c) => {
let cx1 = @cmp.maximum(x, c.x)
let cy1 = @cmp.maximum(y, c.y)
let cx2 = @cmp.minimum(x + w, c.x + c.w)
let cy2 = @cmp.minimum(y + h, c.y + c.h)
if cx2 > cx1 && cy2 > cy1 {
fill_rect_with_types_color(
fb,
palette,
cx1,
cy1,
cx2 - cx1,
cy2 - cy1,
color,
opacity,
)
}
}
}
}
///|
fn fill_box_shadow_clipped(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
w : Int,
h : Int,
color : @types.Color,
opacity : Double,
clip : ClipRect?,
spread : Int,
tl : Int,
tr : Int,
br : Int,
bl : Int,
) -> Unit {
if w <= 0 || h <= 0 || color.is_transparent() {
return
}
let draw_tl = @cmp.maximum(tl + spread, 0)
let draw_tr = @cmp.maximum(tr + spread, 0)
let draw_br = @cmp.maximum(br + spread, 0)
let draw_bl = @cmp.maximum(bl + spread, 0)
let has_radius = draw_tl > 0 || draw_tr > 0 || draw_br > 0 || draw_bl > 0
if has_radius {
fill_rounded_rect_clipped(
fb, palette, x, y, w, h, color, opacity, clip, draw_tl, draw_tr, draw_br, draw_bl,
)
} else {
fill_rect_clipped(fb, palette, x, y, w, h, color, opacity, clip)
}
}
///|
fn blurred_shadow_layer_count(blur : Int) -> Int {
let by_radius = blur / 8 + 4
if by_radius < 4 {
4
} else if by_radius > 12 {
12
} else {
by_radius
}
}
///|
fn fill_blurred_box_shadow_clipped(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
w : Int,
h : Int,
color : @types.Color,
opacity : Double,
clip : ClipRect?,
spread : Int,
blur : Int,
tl : Int,
tr : Int,
br : Int,
bl : Int,
) -> Unit {
if blur <= 0 {
fill_box_shadow_clipped(
fb, palette, x, y, w, h, color, opacity, clip, spread, tl, tr, br, bl,
)
return
}
let steps = blurred_shadow_layer_count(blur)
let layer_opacity = opacity / steps.to_double()
for i = steps; i > 0; i = i - 1 {
let expansion = (blur.to_double() * i.to_double() / steps.to_double()).to_int()
fill_box_shadow_clipped(
fb,
palette,
x - expansion,
y - expansion,
w + expansion * 2,
h + expansion * 2,
color,
layer_opacity,
clip,
spread + expansion,
tl,
tr,
br,
bl,
)
}
}
///|
///|
/// Compute coverage (0.0-1.0) for a pixel near a rounded corner boundary.
/// Returns 1.0 if fully inside, 0.0 if fully outside, fractional at the edge.
fn rounded_corner_coverage(
px : Int,
py : Int,
rect_x : Int,
rect_y : Int,
rect_w : Int,
rect_h : Int,
tl : Int,
tr : Int,
br : Int,
bl : Int,
) -> Double {
let lx = px - rect_x
let ly = py - rect_y
// Check each corner; if pixel is not in any corner zone, fully inside
// Top-left corner
if tl > 0 && lx < tl && ly < tl {
let dx = (tl - lx).to_double() - 0.5
let dy = (tl - ly).to_double() - 0.5
let dist = (dx * dx + dy * dy).sqrt()
let r = tl.to_double()
if dist >= r + 0.5 {
return 0.0
}
if dist <= r - 0.5 {
return 1.0
}
return r + 0.5 - dist
}
// Top-right corner
if tr > 0 && lx >= rect_w - tr && ly < tr {
let dx = (lx - (rect_w - tr - 1)).to_double() - 0.5
let dy = (tr - ly).to_double() - 0.5
let dist = (dx * dx + dy * dy).sqrt()
let r = tr.to_double()
if dist >= r + 0.5 {
return 0.0
}
if dist <= r - 0.5 {
return 1.0
}
return r + 0.5 - dist
}
// Bottom-right corner
if br > 0 && lx >= rect_w - br && ly >= rect_h - br {
let dx = (lx - (rect_w - br - 1)).to_double() - 0.5
let dy = (ly - (rect_h - br - 1)).to_double() - 0.5
let dist = (dx * dx + dy * dy).sqrt()
let r = br.to_double()
if dist >= r + 0.5 {
return 0.0
}
if dist <= r - 0.5 {
return 1.0
}
return r + 0.5 - dist
}
// Bottom-left corner
if bl > 0 && lx < bl && ly >= rect_h - bl {
let dx = (bl - lx).to_double() - 0.5
let dy = (ly - (rect_h - bl - 1)).to_double() - 0.5
let dist = (dx * dx + dy * dy).sqrt()
let r = bl.to_double()
if dist >= r + 0.5 {
return 0.0
}
if dist <= r - 0.5 {
return 1.0
}
return r + 0.5 - dist
}
1.0
}
///|
/// Fill a rounded rect (with optional clip)
fn fill_rounded_rect_clipped(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
w : Int,
h : Int,
color : @types.Color,
opacity : Double,
clip : ClipRect?,
tl : Int,
tr : Int,
br : Int,
bl : Int,
) -> Unit {
let alpha = effective_alpha_byte(color, opacity)
if alpha <= 0 {
return
}
let src = types_color_to_sixel(color)
// Determine draw bounds (intersect with clip)
let x1 = match clip {
Some(c) => @cmp.maximum(x, c.x)
None => x
}
let y1 = match clip {
Some(c) => @cmp.maximum(y, c.y)
None => y
}
let x2 = match clip {
Some(c) => @cmp.minimum(x + w, c.x + c.w)
None => x + w
}
let y2 = match clip {
Some(c) => @cmp.minimum(y + h, c.y + c.h)
None => y + h
}
if x2 <= x1 || y2 <= y1 {
return
}
let has_radius = tl > 0 || tr > 0 || br > 0 || bl > 0
if has_radius {
// Antialiased rounded corners: use coverage-based blending
let idx = palette.get_or_add(src)
for py = y1; py < y2; py = py + 1 {
for px = x1; px < x2; px = px + 1 {
let cov = rounded_corner_coverage(px, py, x, y, w, h, tl, tr, br, bl)
if cov <= 0.0 {
continue
}
let effective_alpha = cov * opacity * color.a
if effective_alpha >= 1.0 {
fb.set_pixel(px, py, idx)
} else if effective_alpha > 0.0 {
// Blend foreground with background using coverage
let dst_idx = fb.get_pixel(px, py)
let dst = palette_color_at(palette, dst_idx)
let a_byte = (effective_alpha * 255.0).to_int()
let blended = src.blend(dst, a_byte)
let blended_idx = palette.get_or_add(blended)
fb.set_pixel(px, py, blended_idx)
}
}
}
} else if alpha >= 255 {
let idx = palette.get_or_add(src)
for py = y1; py < y2; py = py + 1 {
for px = x1; px < x2; px = px + 1 {
fb.set_pixel(px, py, idx)
}
}
} else {
for py = y1; py < y2; py = py + 1 {
for px = x1; px < x2; px = px + 1 {
blend_types_color_over_pixel(fb, palette, px, py, color, opacity)
}
}
}
}
///|
/// Interpolate between two colors at position t (0.0-1.0)
fn lerp_color(a : @types.Color, b : @types.Color, t : Double) -> @types.Color {
let r = (a.r.to_double() * (1.0 - t) + b.r.to_double() * t).to_int()
let g = (a.g.to_double() * (1.0 - t) + b.g.to_double() * t).to_int()
let b_val = (a.b.to_double() * (1.0 - t) + b.b.to_double() * t).to_int()
let alpha = a.a * (1.0 - t) + b.a * t
@types.Color::rgba(clamp_byte(r), clamp_byte(g), clamp_byte(b_val), alpha)
}
///|
/// Sample a gradient at position t (0.0-1.0)
fn sample_gradient(grad : @types.LinearGradient, t : Double) -> @types.Color {
let stops = grad.stops
if stops.length() == 0 {
return @types.Color::transparent()
}
if t <= stops[0].position {
return stops[0].color
}
let last = stops.length() - 1
if t >= stops[last].position {
return stops[last].color
}
// Find the two stops that bracket t
for i = 0; i < last; i = i + 1 {
if t >= stops[i].position && t <= stops[i + 1].position {
let range = stops[i + 1].position - stops[i].position
let local_t = if range > 0.0 {
(t - stops[i].position) / range
} else {
0.0
}
return lerp_color(stops[i].color, stops[i + 1].color, local_t)
}
}
stops[last].color
}
///|
/// Fill a rect with a linear gradient (with optional clip and border-radius)
fn fill_linear_gradient_clipped(
fb : Framebuffer,
palette : DynamicPalette,
x : Int,
y : Int,
w : Int,
h : Int,
grad : @types.LinearGradient,
opacity : Double,
clip : ClipRect?,
tl : Int,
tr : Int,
br : Int,
bl : Int,
) -> Unit {
// Determine draw bounds
let x1 = match clip {
Some(c) => @cmp.maximum(x, c.x)
None => x
}
let y1 = match clip {
Some(c) => @cmp.maximum(y, c.y)
None => y
}
let x2 = match clip {
Some(c) => @cmp.minimum(x + w, c.x + c.w)
None => x + w
}
let y2 = match clip {
Some(c) => @cmp.minimum(y + h, c.y + c.h)
None => y + h
}
if x2 <= x1 || y2 <= y1 {
return
}
let has_radius = tl > 0 || tr > 0 || br > 0 || bl > 0
// Convert angle to direction vector
// CSS gradient angles: 0deg = to top, 90deg = to right, 180deg = to bottom
let pi = 3.14159265358979
let rad = (grad.angle_deg - 90.0) * pi / 180.0
let dx = @math.cos(rad)
let dy = @math.sin(rad)
// Project rect corners onto gradient line to find the extent
let cx = w.to_double() / 2.0
let cy = h.to_double() / 2.0
let half_len = (cx * dx.abs() + cy * dy.abs()).abs()
let inv_len = if half_len > 0.001 { 1.0 / (2.0 * half_len) } else { 0.0 }
for py = y1; py < y2; py = py + 1 {
let fy = (py - y).to_double() - cy
for px = x1; px < x2; px = px + 1 {
let cov = if has_radius {
rounded_corner_coverage(px, py, x, y, w, h, tl, tr, br, bl)
} else {
1.0
}
if cov <= 0.0 {
continue
}
let fx = (px - x).to_double() - cx
let proj = fx * dx + fy * dy
let t = (proj + half_len) * inv_len
// Clamp t
let tc = if t < 0.0 { 0.0 } else if t > 1.0 { 1.0 } else { t }
let color = sample_gradient(grad, tc)
if color.a > 0.0 {
let effective_opacity = opacity * cov
if effective_opacity >= 1.0 && color.a >= 1.0 {
let idx = palette.get_or_add(types_color_to_sixel(color))
fb.set_pixel(px, py, idx)
} else {
blend_types_color_over_pixel(
fb, palette, px, py, color, effective_opacity,
)
}
}
}
}
}
///|
/// Resolve border-radius value: negative means percentage of reference dimension
fn resolve_radius(val : Double, ref_dim : Int) -> Int {
if val < 0.0 {
// Negative encodes percentage: -0.5 means 50%
let frac = -val
(frac * ref_dim.to_double()).to_int()
} else {
val.to_int()
}
}
///|
/// Render a PaintNode tree to framebuffer
fn render_paint_node(
fb : Framebuffer,
palette : DynamicPalette,
node : @paint_model.PaintNode,
parent_x : Double,
parent_y : Double,
) -> Unit {
render_paint_node_clipped(fb, palette, node, parent_x, parent_y, None)
}
///|
/// Render a PaintNode tree to framebuffer with optional clip rect
fn render_paint_node_clipped(
fb : Framebuffer,
palette : DynamicPalette,
node : @paint_model.PaintNode,
parent_x : Double,
parent_y : Double,
clip : ClipRect?,
) -> Unit {
// Skip invisible nodes
if !node.paint.should_render() {
return
}
let abs_x = parent_x + node.x
let abs_y = parent_y + node.y
let x = abs_x.to_int()
let y = abs_y.to_int()
let w = node.width.to_int()
let h = node.height.to_int()
// Skip zero-sized nodes
if w <= 0 || h <= 0 {
for child in node.children {
render_paint_node_clipped(fb, palette, child, abs_x, abs_y, clip)
}
return
}
// Skip "visually hidden" accessibility pattern:
// Elements with clip/overflow clipping and very small size (1px x 1px)
if node.clips_content() && w <= 1 && h <= 1 {
return
}
// Skip very small elements that are likely visually hidden
if w <= 1 && h <= 1 {
return
}
if w <= 1 && node.tag == "#text" {
return
}
// Skip nodes entirely outside the framebuffer
if x >= fb.width || y >= fb.height || x + w <= 0 || y + h <= 0 {
return
}
// If there's a clip rect, check if this node is fully outside it
match clip {
Some(c) =>
if x >= c.x + c.w || y >= c.y + c.h || x + w <= c.x || y + h <= c.y {
return
}
None => ()
}
// Compute border-radius values (negative means percentage)
// Clamp to half of element dimensions per CSS spec
let min_dim = @cmp.minimum(w, h)
let max_r = min_dim / 2
let tl_r = resolve_radius(node.paint.border_top_left_radius, min_dim).min(
max_r,
)
let tr_r = resolve_radius(node.paint.border_top_right_radius, min_dim).min(
max_r,
)
let br_r = resolve_radius(node.paint.border_bottom_right_radius, min_dim).min(
max_r,
)
let bl_r = resolve_radius(node.paint.border_bottom_left_radius, min_dim).min(
max_r,
)
let has_radius = tl_r > 0 || tr_r > 0 || br_r > 0 || bl_r > 0
for shadow in node.paint.box_shadows {
if shadow.inset || shadow.color.is_transparent() {
continue
}
let spread = shadow.spread_radius.to_int()
let blur = shadow.blur_radius.to_int()
let shadow_x = x + shadow.offset_x.to_int() - spread
let shadow_y = y + shadow.offset_y.to_int() - spread
let shadow_w = w + spread * 2
let shadow_h = h + spread * 2
fill_blurred_box_shadow_clipped(
fb,
palette,
shadow_x,
shadow_y,
shadow_w,
shadow_h,
shadow.color,
node.paint.opacity,
clip,
spread,
blur,
tl_r,
tr_r,
br_r,
bl_r,
)
}
// Draw background: gradient or solid color
match node.paint.background_image {
@types.Gradient(grad) =>
fill_linear_gradient_clipped(
fb,
palette,
x,
y,
w,
h,
grad,
node.paint.opacity,
clip,
tl_r,
tr_r,
br_r,
bl_r,
)
@types.None =>
if !node.paint.background_color.is_transparent() {
if has_radius {
fill_rounded_rect_clipped(
fb,
palette,
x,
y,
w,
h,
node.paint.background_color,
node.paint.opacity,
clip,
tl_r,
tr_r,
br_r,
bl_r,
)
} else {
fill_rect_clipped(
fb,
palette,
x,
y,
w,
h,
node.paint.background_color,
node.paint.opacity,
clip,
)
}
}
}
// Draw borders
let bt = node.paint.border_top_width.to_int()
let br = node.paint.border_right_width.to_int()
let bb = node.paint.border_bottom_width.to_int()
let bl = node.paint.border_left_width.to_int()
let opacity = node.paint.opacity
if bt > 0 && !node.paint.border_top_color.is_transparent() {
fill_rect_clipped(
fb,
palette,
x,
y,
w,
bt,
node.paint.border_top_color,
opacity,
clip,
)
}
if bb > 0 && !node.paint.border_bottom_color.is_transparent() {
fill_rect_clipped(
fb,
palette,
x,
y + h - bb,
w,
bb,
node.paint.border_bottom_color,
opacity,
clip,
)
}
if bl > 0 && !node.paint.border_left_color.is_transparent() {
fill_rect_clipped(
fb,
palette,
x,
y + bt,
bl,
h - bt - bb,
node.paint.border_left_color,
opacity,
clip,
)
}
if br > 0 && !node.paint.border_right_color.is_transparent() {
fill_rect_clipped(
fb,
palette,
x + w - br,
y + bt,
br,
h - bt - bb,
node.paint.border_right_color,
opacity,
clip,
)
}
// Draw resolved image content before alt fallback text.
let drew_replaced_image = if node.tag == "img" && w > 0 && h > 0 {
match node.src {
Some(src) =>
render_image_src_into_region(
fb,
palette,
src,
x,
y,
w,
h,
clip,
node.paint.opacity,
)
None => false
}
} else {
false
}
if !drew_replaced_image {
// Draw text if this is a text node
match node.text {
Some(text_content) => {
let (text_x, text_y, text_w) = resolve_text_render_box(node, x, y, w, h)
match glyph_provider_override.val {
Some(provider) => {
let svg_color = @svg.Color::rgb(
node.paint.color.r,
node.paint.color.g,
node.paint.color.b,
)
let font_size = node.paint.font_size
let wrap_width = resolve_glyph_text_wrap_width(
text_w, h, font_size, text_content,
)
let _ = draw_text_with_glyphs_clipped(
fb,
palette,
provider,
text_x,
text_y,
text_content,
wrap_width,
svg_color,
font_size,
font_size,
node.paint.is_bold,
clip,
font_family=node.paint.font_family,
opacity=node.paint.opacity,
)
}
None => {
let text_idx = palette.get_or_add(
types_color_to_sixel(node.paint.color),
)
let scale = 1
let _ = draw_text_clipped(
fb, text_x, text_y, text_content, text_w, text_idx, scale, clip,
)
}
}
}
None => ()
}
// Draw text decorations
match node.text {
Some(_) => {
let font_size = node.paint.font_size
if node.paint.text_decoration_overline {
draw_text_decoration_line(
fb,
palette,
x,
y + (font_size * 0.15).to_int(),
w,
node.paint.color,
node.paint.opacity,
clip,
)
}
if node.paint.text_decoration_line_through {
draw_text_decoration_line(
fb,
palette,
x,
y + (font_size * 0.6).to_int(),
w,
node.paint.color,
node.paint.opacity,
clip,
)
}
if node.paint.text_decoration_underline {
draw_text_decoration_line(
fb,
palette,
x,
y + (font_size * 1.1).to_int(),
w,
node.paint.color,
node.paint.opacity,
clip,
)
}
}
None => ()
}
}
// Compute child clip: if this node clips content, intersect with current clip
let child_clip = if node.clips_content() {
let node_clip : ClipRect = { x, y, w, h }
match clip {
None => Some(node_clip)
Some(parent_clip) => clip_intersect(parent_clip, node_clip)
}
} else {
clip
}
// If clip intersection is empty, skip all children
match (node.clips_content(), child_clip) {
(true, None) => return
_ => ()
}
// Render children with (possibly narrowed) clip
// Propagate opacity to children (CSS opacity affects entire subtree)
if node.paint.opacity < 1.0 {
for child in node.children {
let child_with_opacity = {
..child,
paint: {
..child.paint,
opacity: child.paint.opacity * node.paint.opacity,
},
}
render_paint_node_clipped(
fb, palette, child_with_opacity, abs_x, abs_y, child_clip,
)
}
} else {
for child in node.children {
render_paint_node_clipped(fb, palette, child, abs_x, abs_y, child_clip)
}
}
}
///|
fn resolve_image_src(src : String) -> ResolvedImage? {
match image_provider_override.val {
Some(provider) =>
match (provider.resolve)(src) {
Some(image) => Some(image)
None =>
if src.has_prefix("data:image/svg+xml") {
let svg_text = decode_svg_data_uri(src)
if svg_text.length() > 0 {
Some(ResolvedImage::Svg(svg_text))
} else {
None
}
} else {
None
}
}
None =>
if src.has_prefix("data:image/svg+xml") {
let svg_text = decode_svg_data_uri(src)
if svg_text.length() > 0 {
Some(ResolvedImage::Svg(svg_text))
} else {
None
}
} else {
None
}
}
}
///|
fn render_image_src_into_region(
fb : Framebuffer,
palette : DynamicPalette,
src : String,
x : Int,
y : Int,
w : Int,
h : Int,
clip : ClipRect?,
opacity : Double,
) -> Bool {
match resolve_image_src(src) {
Some(ResolvedImage::Raster(image)) =>
render_raster_image_into_region(
fb, palette, image, x, y, w, h, clip, opacity,
)
Some(ResolvedImage::Svg(svg_text)) =>
if svg_text.length() > 0 {
render_svg_into_region(fb, palette, svg_text, x, y, w, h, clip)
true
} else {
false
}
None => false
}
}
///|
fn render_raster_image_into_region(
fb : Framebuffer,
palette : DynamicPalette,
image : RasterImage,
x : Int,
y : Int,
w : Int,
h : Int,
clip : ClipRect?,
opacity : Double,
) -> Bool {
if image.width <= 0 ||
image.height <= 0 ||
w <= 0 ||
h <= 0 ||
image.rgba.length() < image.width * image.height * 4 {
return false
}
for py = 0; py < h; py = py + 1 {
let dst_y = y + py
if dst_y < 0 || dst_y >= fb.height {
continue
}
let src_y = (py * image.height / h).min(image.height - 1)
for px = 0; px < w; px = px + 1 {
let dst_x = x + px
if dst_x < 0 || dst_x >= fb.width {
continue
}
if !pixel_in_clip(clip, dst_x, dst_y) {
continue
}
let src_x = (px * image.width / w).min(image.width - 1)
let idx = (src_y * image.width + src_x) * 4
let alpha = clamp_byte(
(image.rgba[idx + 3].to_double() * opacity).to_int(),
)
if alpha <= 0 {
continue
}
blend_sixel_color_over_pixel(
fb,
palette,
dst_x,
dst_y,
{
r: clamp_byte(image.rgba[idx]),
g: clamp_byte(image.rgba[idx + 1]),
b: clamp_byte(image.rgba[idx + 2]),
},
alpha,
)
}
}
true
}
///|
/// Decode SVG data URI to SVG text
fn decode_svg_data_uri(src : String) -> String {
// Handle data:image/svg+xml, (URL-encoded)
// and data:image/svg+xml;utf8,
let prefixes = [
"data:image/svg+xml,", "data:image/svg+xml;utf8,", "data:image/svg+xml;charset=utf-8,",
]
for prefix in prefixes {
if src.has_prefix(prefix) {
let encoded = src.unsafe_substring(
start=prefix.length(),
end=src.length(),
)
return url_decode_simple(encoded)
}
}
// data:image/svg+xml;base64,... not handled yet
""
}
///|
/// Simple URL decode (%XX → char)
fn url_decode_simple(s : String) -> String {
let buf = StringBuilder::new()
let chars = s.to_array()
let len = chars.length()
let mut i = 0
while i < len {
if chars[i] == '%' && i + 2 < len {
let hi = hex_digit(chars[i + 1])
let lo = hex_digit(chars[i + 2])
if hi >= 0 && lo >= 0 {
buf.write_char((hi * 16 + lo).unsafe_to_char())
i = i + 3
continue
}
}
buf.write_char(chars[i])
i = i + 1
}
buf.to_string()
}
///|
fn hex_digit(c : Char) -> Int {
if c >= '0' && c <= '9' {
c.to_int() - '0'.to_int()
} else if c >= 'a' && c <= 'f' {
c.to_int() - 'a'.to_int() + 10
} else if c >= 'A' && c <= 'F' {
c.to_int() - 'A'.to_int() + 10
} else {
-1
}
}
///|
/// Render SVG into a region of the framebuffer
fn render_svg_into_region(
fb : Framebuffer,
palette : DynamicPalette,
svg_text : String,
x : Int,
y : Int,
w : Int,
h : Int,
clip : ClipRect?,
) -> Unit {
let svg_node = match @svg.parse_svg(svg_text) {
Some(n) => n
None => return
}
let scene = @svg.Scene::new(svg_node)
let bounds = scene.get_bounds()
let vw = bounds.width()
let vh = bounds.height()
if vw <= 0.0 || vh <= 0.0 {
return
}
// Render SVG to temporary framebuffer at target size
let tmp_fb = Framebuffer::new(w, h)
let tmp_palette = DynamicPalette::new()
tmp_fb.fill_rect(0, 0, w, h, 0)
let camera = @svg.Camera::new(w, h)
camera.set_position(vw / 2.0, vh / 2.0)
let zoom = (w.to_double() / vw).min(h.to_double() / vh)
camera.set_zoom(zoom)
render_svg_scene_with_camera(scene, tmp_fb, tmp_palette, camera)
// Blit temporary framebuffer into main framebuffer
for py = 0; py < h; py = py + 1 {
let dst_y = y + py
if dst_y < 0 || dst_y >= fb.height {
continue
}
for px = 0; px < w; px = px + 1 {
let dst_x = x + px
if dst_x < 0 || dst_x >= fb.width {
continue
}
if !pixel_in_clip(clip, dst_x, dst_y) {
continue
}
let tmp_idx = tmp_fb.pixels[py * w + px]
if tmp_idx > 0 {
// Non-white pixel: map from tmp palette to main palette
let color = if tmp_idx < tmp_palette.colors.length() {
tmp_palette.colors[tmp_idx]
} else {
Color::white()
}
let main_idx = palette.get_or_add(color)
fb.set_pixel(dst_x, dst_y, main_idx)
}
}
}
}
///|
/// Convert framebuffer to Sixel string with dynamic palette
pub fn to_sixel_dynamic(fb : Framebuffer, palette : DynamicPalette) -> String {
let buf = StringBuilder::new()
let palette_len = palette.colors.length()
let color_headers = build_sixel_color_headers(palette_len)
// Sixel start sequence: ESC P q
buf.write_string("\u001bPq")
// Define color palette
buf.write_string(build_sixel_palette_definition(palette.colors))
// Process in bands of 6 pixels height
let num_bands = (fb.height + 5) / 6
for band = 0; band < num_bands; band = band + 1 {
let y_start = band * 6
let active_colors = Array::make(palette_len, false)
let band_rows = Array::make(palette_len * fb.width, 0)
for bit = 0; bit < 6; bit = bit + 1 {
let y = y_start + bit
if y >= fb.height {
continue
}
let row_offset = y * fb.width
let bit_mask = sixel_bit_masks[bit]
for x = 0; x < fb.width; x = x + 1 {
let color_idx = fb.pixels[row_offset + x]
active_colors[color_idx] = true
let cell_idx = color_idx * fb.width + x
band_rows[cell_idx] = band_rows[cell_idx] | bit_mask
}
}
for color_idx = 0; color_idx < palette_len; color_idx = color_idx + 1 {
if !active_colors[color_idx] {
continue
}
buf.write_string(color_headers[color_idx])
let row_base = color_idx * fb.width
write_sixel_row_compressed(buf, band_rows, row_base, fb.width)
buf.write_char('$')
}
buf.write_char('-')
}
buf.write_string("\u001b\\")
buf.to_string()
}
///|
/// Render PaintNode tree to Sixel string with actual CSS colors
pub fn render_paint_node_to_sixel(
node : @paint_model.PaintNode,
width : Int,
height : Int,
) -> String {
let fb = Framebuffer::new(width, height)
let palette = DynamicPalette::new()
// Fill with white background
fb.fill_rect(0, 0, width, height, 0)
// Sort by stacking order (z-index) for correct rendering
let sorted_node = @paint_traversal.sort_tree_by_stacking_order(node)
// Collect all colors first
collect_colors(palette, sorted_node)
// Render the tree
render_paint_node(fb, palette, sorted_node, 0.0, 0.0)
// Convert to Sixel
to_sixel_dynamic(fb, palette)
}
///|
/// Render Node + Layout to Sixel string with CSS colors
pub fn render_with_styles(
node : @node.Node,
layout : @types.Layout,
width : Int,
height : Int,
) -> String {
let paint_node = @paint_node.from_node_and_layout(node, layout)
render_paint_node_to_sixel(paint_node, width, height)
}
///|
/// Render PaintNode tree to Sixel string with scroll offset
pub fn render_paint_node_to_sixel_scrolled(
node : @paint_model.PaintNode,
width : Int,
height : Int,
scroll_y : Int,
) -> String {
let fb = Framebuffer::new(width, height)
let palette = DynamicPalette::new()
render_paint_node_to_framebuffer_scrolled(node, fb, palette, scroll_y)
// Convert to Sixel
to_sixel_dynamic(fb, palette)
}
///|
/// Render PaintNode tree to framebuffer with scroll offset
pub fn render_paint_node_to_framebuffer_scrolled(
node : @paint_model.PaintNode,
fb : Framebuffer,
palette : DynamicPalette,
scroll_y : Int,
) -> Unit {
// Fill with white background
fb.fill_rect(0, 0, fb.width, fb.height, 0)
// Sort by stacking order (z-index) for correct rendering
let sorted_node = @paint_traversal.sort_tree_by_stacking_order(node)
// Collect all colors first
collect_colors(palette, sorted_node)
// CSS canvas background propagation (CSS 2.1 §14.2):
// If root () background is transparent, use background instead
let mut canvas_bg = sorted_node.paint.background_color
if canvas_bg.is_transparent() {
for child in sorted_node.children {
if child.tag == "body" && !child.paint.background_color.is_transparent() {
canvas_bg = child.paint.background_color
break
}
}
}
if !canvas_bg.is_transparent() {
let bg_sixel = { r: canvas_bg.r, g: canvas_bg.g, b: canvas_bg.b }
let bg_idx = palette.get_or_add(bg_sixel)
fb.fill_rect(0, 0, fb.width, fb.height, bg_idx)
}
// Render the tree with scroll offset
render_paint_node(fb, palette, sorted_node, 0.0, -scroll_y.to_double())
}
///|
/// Render Node + Layout to Sixel string with CSS colors and scroll offset
pub fn render_with_styles_scrolled(
node : @node.Node,
layout : @types.Layout,
width : Int,
height : Int,
scroll_y : Int,
) -> String {
let paint_node = @paint_node.from_node_and_layout(node, layout)
render_paint_node_to_sixel_scrolled(paint_node, width, height, scroll_y)
}
///|
/// Render PaintNode tree to framebuffer (for use by other renderers like Kitty)
pub fn render_paint_node_to_framebuffer(
node : @paint_model.PaintNode,
fb : Framebuffer,
palette : DynamicPalette,
) -> Unit {
// Sort by stacking order (z-index) for correct rendering
let sorted_node = @paint_traversal.sort_tree_by_stacking_order(node)
// Collect all colors first
collect_colors(palette, sorted_node)
// CSS canvas background propagation (CSS 2.1 §14.2):
// If root () background is transparent, use background instead
let mut canvas_bg = sorted_node.paint.background_color
if canvas_bg.is_transparent() {
// Look for body element among root's children
for child in sorted_node.children {
if child.tag == "body" && !child.paint.background_color.is_transparent() {
canvas_bg = child.paint.background_color
break
}
}
}
if !canvas_bg.is_transparent() {
let bg_sixel = { r: canvas_bg.r, g: canvas_bg.g, b: canvas_bg.b }
let bg_idx = palette.get_or_add(bg_sixel)
fb.fill_rect(0, 0, fb.width, fb.height, bg_idx)
}
// Render the tree
render_paint_node(fb, palette, sorted_node, 0.0, 0.0)
}