///|
fn extract_string_value_from_evaluate_result(json_str : String) -> String {
let parsed = @json.parse(json_str) catch { _ => return "" }
match parsed {
Object(map) =>
match map.get("result") {
Some(Object(result_map)) =>
match result_map.get("value") {
Some(String(val)) => val
_ => ""
}
_ => ""
}
_ => ""
}
}
///|
/// Check if JS-side text metrics provider is available
extern "js" fn has_paint_text_provider() -> Bool =
#|() => typeof globalThis.__craterMeasureTextIntrinsic === "function"
///|
/// Call font-family-aware text metrics provider
extern "js" fn call_font_aware_text_provider(
text : String,
font_size : Double,
line_height : Double,
white_space : String,
writing_mode : String,
available_width : Double,
available_height : Double,
font_family : String,
is_bold : Bool,
) -> String =
#|(text, fontSize, lineHeight, whiteSpace, writingMode, availableWidth, availableHeight, fontFamily, isBold) => {
#| // Try multi-font provider first
#| const multiFn = globalThis.__craterMeasureTextIntrinsicMultiFull;
#| if (typeof multiFn === "function") {
#| try {
#| const result = multiFn(text, fontSize, lineHeight, whiteSpace, writingMode, availableWidth, availableHeight, fontFamily, isBold);
#| if (result) {
#| const mw = Number(result.minWidth ?? result.min_width);
#| const xw = Number(result.maxWidth ?? result.max_width);
#| const mh = Number(result.minHeight ?? result.min_height);
#| const xh = Number(result.maxHeight ?? result.max_height);
#| if (Number.isFinite(mw) && Number.isFinite(xw) && Number.isFinite(mh) && Number.isFinite(xh))
#| return `${mw},${xw},${mh},${xh}`;
#| }
#| } catch {}
#| }
#| // Fallback to default provider
#| const fn = isBold ? (globalThis.__craterMeasureTextIntrinsicBold || globalThis.__craterMeasureTextIntrinsic) : globalThis.__craterMeasureTextIntrinsic;
#| if (typeof fn !== "function") return "";
#| try {
#| const result = fn(text, fontSize, lineHeight, whiteSpace, writingMode, availableWidth, availableHeight);
#| let minWidth, maxWidth, minHeight, maxHeight;
#| if (result && typeof result === "object") {
#| minWidth = Number(result.minWidth ?? result.min_width);
#| maxWidth = Number(result.maxWidth ?? result.max_width);
#| minHeight = Number(result.minHeight ?? result.min_height);
#| maxHeight = Number(result.maxHeight ?? result.max_height);
#| } else return "";
#| if (!Number.isFinite(minWidth) || !Number.isFinite(maxWidth)) return "";
#| return `${minWidth},${maxWidth},${minHeight},${maxHeight}`;
#| } catch { return ""; }
#|}
///|
/// Call JS-side text metrics provider (returns CSV: "minWidth,maxWidth,minHeight,maxHeight")
extern "js" fn call_paint_text_provider(
text : String,
font_size : Double,
line_height : Double,
white_space : String,
writing_mode : String,
available_width : Double,
available_height : Double,
) -> String =
#|(text, fontSize, lineHeight, whiteSpace, writingMode, availableWidth, availableHeight) => {
#| const fn = globalThis.__craterMeasureTextIntrinsic;
#| if (typeof fn !== "function") return "";
#| try {
#| const result = fn(text, fontSize, lineHeight, whiteSpace, writingMode, availableWidth, availableHeight);
#| let minWidth, maxWidth, minHeight, maxHeight;
#| if (Array.isArray(result) && result.length >= 4) {
#| minWidth = Number(result[0]); maxWidth = Number(result[1]);
#| minHeight = Number(result[2]); maxHeight = Number(result[3]);
#| } else if (result && typeof result === "object") {
#| minWidth = Number(result.minWidth ?? result.min_width);
#| maxWidth = Number(result.maxWidth ?? result.max_width ?? minWidth);
#| minHeight = Number(result.minHeight ?? result.min_height);
#| maxHeight = Number(result.maxHeight ?? result.max_height ?? minHeight);
#| } else { return ""; }
#| if (!Number.isFinite(minWidth) || !Number.isFinite(maxWidth) || !Number.isFinite(minHeight) || !Number.isFinite(maxHeight)) return "";
#| return `${minWidth},${maxWidth},${minHeight},${maxHeight}`;
#| } catch { return ""; }
#|}
///|
/// Check if JS-side outline commands provider is available (avoids SVG string roundtrip)
extern "js" fn has_outline_commands() -> Bool =
#|() => typeof globalThis.__craterGlyphOutlineCommands === "function"
///|
/// Get glyph outline commands as JSON string: [[type, ...args], ...]
extern "js" fn js_glyph_outline_commands(
codepoint : Int,
font_size : Double,
is_bold : Bool,
) -> String =
#|(cp, fs, bold) => {
#| const fn = bold ? (globalThis.__craterGlyphOutlineCommandsBold || globalThis.__craterGlyphOutlineCommands) : globalThis.__craterGlyphOutlineCommands;
#| if (typeof fn !== "function") return "";
#| try { return String(fn(cp, fs) || ""); } catch { return ""; }
#|}
///|
/// Get glyph outline commands for a specific font family as JSON string
extern "js" fn js_outline_commands_for_family(
codepoint : Int,
font_size : Double,
is_bold : Bool,
font_family : String,
) -> String =
#|(cp, fs, bold, ff) => {
#| const fn = globalThis.__craterOutlineCommandsForFamily;
#| if (typeof fn !== "function") return "";
#| try { return String(fn(cp, fs, bold, ff) || ""); } catch { return ""; }
#|}
///|
/// Parse JSON array of outline commands into Array[@svg.PathCommand]
/// Format: [["M",x,y], ["L",x,y], ["Q",cx,cy,x,y], ["C",cx1,cy1,cx2,cy2,x,y], ["Z"]]
fn parse_outline_commands_json(json_str : String) -> Array[@svg.PathCommand] {
let result : Array[@svg.PathCommand] = []
let parsed = @json.parse(json_str) catch { _ => return result }
guard parsed is Array(cmds) else { return result }
for cmd in cmds {
guard cmd is Array(parts) else { continue }
if parts.length() == 0 {
continue
}
guard parts[0] is String(typ) else { continue }
match typ {
"M" =>
if parts.length() >= 3 {
guard parts[1] is Number(x, ..) else { continue }
guard parts[2] is Number(y, ..) else { continue }
result.push(@svg.MoveTo(x, y))
}
"L" =>
if parts.length() >= 3 {
guard parts[1] is Number(x, ..) else { continue }
guard parts[2] is Number(y, ..) else { continue }
result.push(@svg.LineTo(x, y))
}
"Q" =>
if parts.length() >= 5 {
guard parts[1] is Number(cx, ..) else { continue }
guard parts[2] is Number(cy, ..) else { continue }
guard parts[3] is Number(x, ..) else { continue }
guard parts[4] is Number(y, ..) else { continue }
result.push(@svg.QuadraticCurveTo(cx, cy, x, y))
}
"C" =>
if parts.length() >= 7 {
guard parts[1] is Number(cx1, ..) else { continue }
guard parts[2] is Number(cy1, ..) else { continue }
guard parts[3] is Number(cx2, ..) else { continue }
guard parts[4] is Number(cy2, ..) else { continue }
guard parts[5] is Number(x, ..) else { continue }
guard parts[6] is Number(y, ..) else { continue }
result.push(@svg.CurveTo(cx1, cy1, cx2, cy2, x, y))
}
"Z" => result.push(@svg.ClosePath)
_ => ()
}
}
result
}
///|
/// Check if JS-side glyph functions are available (mizchi/font loaded)
extern "js" fn has_glyph_functions() -> Bool =
#|() => typeof globalThis.__craterGlyphToSvgPath === "function"
///|
/// Get SVG path string for a glyph
extern "js" fn js_glyph_to_svg_path(
codepoint : Int,
font_size : Double,
is_bold : Bool,
) -> String =
#|(cp, fs, bold) => {
#| const fn = bold ? (globalThis.__craterGlyphToSvgPathBold || globalThis.__craterGlyphToSvgPath) : globalThis.__craterGlyphToSvgPath;
#| if (typeof fn !== "function") return "";
#| try { return String(fn(cp, fs) || ""); } catch { return ""; }
#|}
///|
/// Get advance width for a glyph
extern "js" fn js_glyph_advance(
codepoint : Int,
font_size : Double,
is_bold : Bool,
) -> Double =
#|(cp, fs, bold) => {
#| const fn = bold ? (globalThis.__craterGlyphAdvanceBold || globalThis.__craterGlyphAdvance) : globalThis.__craterGlyphAdvance;
#| if (typeof fn !== "function") return fs * 0.5;
#| try { const v = Number(fn(cp, fs)); return Number.isFinite(v) ? v : fs * 0.5; } catch { return fs * 0.5; }
#|}
///|
/// Get kerning between two codepoints
extern "js" fn js_kern_advance(
cp1 : Int,
cp2 : Int,
font_size : Double,
is_bold : Bool,
) -> Double =
#|(cp1, cp2, fs, bold) => {
#| const fn = bold ? (globalThis.__craterKernAdvanceBold || globalThis.__craterKernAdvance) : globalThis.__craterKernAdvance;
#| if (typeof fn !== "function") return 0;
#| try { const v = Number(fn(cp1, cp2, fs)); return Number.isFinite(v) ? v : 0; } catch { return 0; }
#|}
///|
/// Get font ascent ratio (ascent / unitsPerEm)
extern "js" fn js_font_ascent_ratio() -> Double =
#|() => {
#| const fn = globalThis.__craterFontAscentRatio;
#| if (typeof fn === "function") { const v = Number(fn()); return Number.isFinite(v) ? v : 0.8; }
#| return 0.8;
#|}
///|
/// Multi-font glyph: get SVG path for a codepoint using font_family
extern "js" fn js_glyph_for_family(
codepoint : Int,
font_size : Double,
is_bold : Bool,
font_family : String,
) -> String =
#|(cp, fs, bold, ff) => {
#| const fn = globalThis.__craterGlyphForFamily;
#| if (typeof fn !== "function") return "";
#| try { return String(fn(cp, fs, bold, ff) || ""); } catch { return ""; }
#|}
///|
extern "js" fn js_advance_for_family(
codepoint : Int,
font_size : Double,
is_bold : Bool,
font_family : String,
) -> Double =
#|(cp, fs, bold, ff) => {
#| const fn = globalThis.__craterAdvanceForFamily;
#| if (typeof fn !== "function") return fs * 0.5;
#| try { const v = Number(fn(cp, fs, bold, ff)); return Number.isFinite(v) ? v : fs * 0.5; } catch { return fs * 0.5; }
#|}
///|
extern "js" fn js_kern_for_family(
cp1 : Int,
cp2 : Int,
font_size : Double,
is_bold : Bool,
font_family : String,
) -> Double =
#|(cp1, cp2, fs, bold, ff) => {
#| const fn = globalThis.__craterKernForFamily;
#| if (typeof fn !== "function") return 0;
#| try { const v = Number(fn(cp1, cp2, fs, bold, ff)); return Number.isFinite(v) ? v : 0; } catch { return 0; }
#|}
///|
extern "js" fn js_ascent_for_family(font_family : String) -> Double =
#|(ff) => {
#| const fn = globalThis.__craterAscentForFamily;
#| if (typeof fn !== "function") return 0.8;
#| try { const v = Number(fn(ff)); return Number.isFinite(v) ? v : 0.8; } catch { return 0.8; }
#|}
///|
extern "js" fn has_bold_text_provider() -> Bool =
#|() => typeof globalThis.__craterMeasureTextIntrinsicBold === "function"
///|
extern "js" fn call_bold_text_provider(
text : String,
font_size : Double,
line_height : Double,
white_space : String,
writing_mode : String,
available_width : Double,
available_height : Double,
) -> String =
#|(text, fontSize, lineHeight, whiteSpace, writingMode, availableWidth, availableHeight) => {
#| const fn = globalThis.__craterMeasureTextIntrinsicBold;
#| if (typeof fn !== "function") return "";
#| try {
#| const result = fn(text, fontSize, lineHeight, whiteSpace, writingMode, availableWidth, availableHeight);
#| let minWidth, maxWidth, minHeight, maxHeight;
#| if (result && typeof result === "object") {
#| minWidth = Number(result.minWidth ?? result.min_width);
#| maxWidth = Number(result.maxWidth ?? result.max_width ?? minWidth);
#| minHeight = Number(result.minHeight ?? result.min_height);
#| maxHeight = Number(result.maxHeight ?? result.max_height ?? minHeight);
#| } else { return ""; }
#| if (!Number.isFinite(minWidth) || !Number.isFinite(maxWidth) || !Number.isFinite(minHeight) || !Number.isFinite(maxHeight)) return "";
#| return `${minWidth},${maxWidth},${minHeight},${maxHeight}`;
#| } catch { return ""; }
#|}
///|
fn parse_csv_quad(s : String) -> (Double, Double, Double, Double)? {
let parts = s.split(",").collect()
if parts.length() < 4 {
return None
}
let a = @string.parse_double(parts[0]) catch { _ => return None }
let b = @string.parse_double(parts[1]) catch { _ => return None }
let c = @string.parse_double(parts[2]) catch { _ => return None }
let d = @string.parse_double(parts[3]) catch { _ => return None }
Some((a, b, c, d))
}
///|
fn should_use_font_aware_text_provider(font_family : String) -> Bool {
let normalized = font_family.trim().to_lower()
normalized.length() > 0 &&
normalized != "inherit" &&
normalized != "initial" &&
normalized != "unset" &&
normalized != "revert" &&
normalized != "revert-layer"
}
///|
fn ensure_paint_text_provider() -> Unit {
if has_paint_text_provider() {
let provider = @renderer.TextMetricsProvider::new(fn(
text,
font_size,
line_height,
white_space,
writing_mode,
font_weight,
font_family,
) {
let ws = white_space.to_string()
let wm = writing_mode.to_string()
let is_bold = font_weight >= 700.0
let ff = font_family
{
func: fn(available_width, available_height) {
let payload = if should_use_font_aware_text_provider(ff) {
let multi = call_font_aware_text_provider(
text, font_size, line_height, ws, wm, available_width, available_height,
ff, is_bold,
)
if multi.length() > 0 {
multi
} else if is_bold && has_bold_text_provider() {
call_bold_text_provider(
text, font_size, line_height, ws, wm, available_width, available_height,
)
} else {
call_paint_text_provider(
text, font_size, line_height, ws, wm, available_width, available_height,
)
}
} else if is_bold && has_bold_text_provider() {
call_bold_text_provider(
text, font_size, line_height, ws, wm, available_width, available_height,
)
} else {
call_paint_text_provider(
text, font_size, line_height, ws, wm, available_width, available_height,
)
}
match parse_csv_quad(payload) {
Some((min_w, max_w, min_h, max_h)) =>
{
min_width: min_w,
max_width: max_w,
min_height: min_h,
max_height: max_h,
}
None => {
let mut cols = 0
for _ in text.iter() {
cols = cols + 1
}
let cw = if font_size > 0.0 { font_size * 0.5 } else { 8.0 }
let w = cols.to_double() * cw
let h = if line_height > 0.0 {
line_height
} else if font_size > 0.0 {
font_size
} else {
16.0
}
{ min_width: w, max_width: w, min_height: h, max_height: h }
}
}
},
}
})
@renderer.set_text_metrics_provider(provider)
}
}
///|
let paint_provider_initialized : Ref[Bool] = { val: false }
///|
/// Reset paint provider state on new WebSocket session.
/// Must be called when JS runtime is reset to avoid stale glyph provider.
pub fn reset_paint_provider() -> Unit {
paint_provider_initialized.val = false
@image.clear_glyph_provider()
@renderer.clear_text_metrics_provider()
}
///|
extern "js" fn js_now_ms() -> Int =
#|() => Date.now()
///|
extern "js" fn js_log_stderr(msg : String) -> Unit =
#|(msg) => { console.error("[paint] " + msg); }
///|
/// Set up MoonBit TextMetricsProvider from JS globalThis.__craterMeasureTextIntrinsic.
/// The JS-side provider is set up by start-with-font.ts at BiDi server startup.
fn ensure_paint_provider_initialized() -> Unit {
if paint_provider_initialized.val {
return
}
paint_provider_initialized.val = true
let has_provider = has_paint_text_provider()
js_log_stderr("has_paint_text_provider=" + has_provider.to_string())
ensure_paint_text_provider()
// Set up glyph provider for text rendering
// Prefer outline commands (JSON array) over SVG string to avoid parse_path roundtrip
let use_outline_commands = has_outline_commands()
if use_outline_commands || has_glyph_functions() {
if use_outline_commands {
js_log_stderr(
"Installing glyph provider (outline commands, no SVG roundtrip)",
)
} else {
js_log_stderr("Installing glyph provider (SVG string fallback)")
}
let ascent_ratio = js_font_ascent_ratio()
js_log_stderr("ascent_ratio=" + ascent_ratio.to_string())
let glyph_provider : @image.GlyphProvider = {
ascent_ratio,
get_kern: fn(cp1, cp2, font_size, is_bold) {
js_kern_advance(cp1, cp2, font_size, is_bold)
},
get_glyph: fn(codepoint, font_size, is_bold) {
let advance = js_glyph_advance(codepoint, font_size, is_bold)
if use_outline_commands {
let json = js_glyph_outline_commands(codepoint, font_size, is_bold)
if json.length() == 0 || json == "[]" {
return ([], advance)
}
let commands = parse_outline_commands_json(json)
(commands, advance)
} else {
let svg_path = js_glyph_to_svg_path(codepoint, font_size, is_bold)
if svg_path.length() == 0 {
return ([], advance)
}
let commands = @svg.parse_path(svg_path)
(commands, advance)
}
},
get_glyph_for_family: Some(fn(
codepoint,
font_size,
is_bold,
font_family,
) {
if use_outline_commands {
let json = js_outline_commands_for_family(
codepoint, font_size, is_bold, font_family,
)
if json.length() == 0 || json == "[]" {
return None
}
let advance = js_advance_for_family(
codepoint, font_size, is_bold, font_family,
)
let commands = parse_outline_commands_json(json)
Some((commands, advance))
} else {
let svg_path = js_glyph_for_family(
codepoint, font_size, is_bold, font_family,
)
if svg_path.length() == 0 {
return None
}
let advance = js_advance_for_family(
codepoint, font_size, is_bold, font_family,
)
let commands = @svg.parse_path(svg_path)
Some((commands, advance))
}
}),
get_kern_for_family: Some(fn(cp1, cp2, font_size, is_bold, font_family) {
js_kern_for_family(cp1, cp2, font_size, is_bold, font_family)
}),
get_ascent_for_family: Some(fn(font_family) {
js_ascent_for_family(font_family)
}),
}
@image.set_glyph_provider(glyph_provider)
}
}
///|
/// Pre-rasterize common ASCII glyphs. Initializes providers if needed.
pub fn warmup_glyph_cache() -> Unit {
ensure_paint_provider_initialized()
let t_pre = js_now_ms()
let common_sizes : Array[Double] = [
13.33, // 10pt (most common body text)
16.0, // 16px (browser default)
]
let n = @image.pre_rasterize_glyphs(common_sizes)
let t_pre_done = js_now_ms()
js_log_stderr(
"Pre-rasterized " +
n.to_string() +
" glyphs in " +
(t_pre_done - t_pre).to_string() +
"ms",
)
}
///|
/// Test-only actual paint capture for VRT.
fn BidiProtocol::handle_capture_paint_data(
self : BidiProtocol,
request_id : Int,
params : Json?,
) -> Unit {
match self.resolve_capture_paint_data(request_id, params) {
Some(result) => self.send_success(request_id, Some(result))
None => ()
}
}
///|
fn BidiProtocol::resolve_capture_paint_data(
self : BidiProtocol,
request_id : Int,
params : Json?,
) -> Json? {
let params = match params {
Some(Object(map)) => map
_ => {
self.send_error(
request_id, "invalid argument", "params must be an object",
)
return None
}
}
let ctx_id = match params.get("context") {
Some(String(id)) => id
Some(_) => {
self.send_error(
request_id, "invalid argument", "context must be a string",
)
return None
}
None => {
self.send_error(request_id, "invalid argument", "Missing context")
return None
}
}
match params.get("origin") {
None | Some(String("viewport")) => ()
Some(String(_)) => {
self.send_error(
request_id, "unsupported operation", "capturePaintData currently supports only origin='viewport'",
)
return None
}
Some(_) => {
self.send_error(
request_id, "invalid argument", "origin must be 'viewport'",
)
return None
}
}
let _session = match self.manager.get_session(ctx_id) {
Some(session) => session
None => {
self.send_error(request_id, "no such frame", "Unknown context: " + ctx_id)
return None
}
}
// Set up MoonBit-side text metrics provider if JS-side is available (lazy init)
ensure_paint_provider_initialized()
let width = self.resolve_effective_viewport_width(ctx_id)
let height = self.resolve_effective_viewport_height(ctx_id)
// Use original HTML from __lastHTML (preserves attributes like class)
// __loadHTML stores the original HTML string in globalThis.__lastHTML
let html_json = evaluate_js("globalThis.__lastHTML || ''")
let html = extract_string_value_from_evaluate_result(html_json)
let ctx = @browser_contract.create_render_context(width, height, false)
let t0 = js_now_ms()
let (node, layout) = @renderer.render_to_node_and_layout(html, ctx)
let t1 = js_now_ms()
let paint_node = match
@tui_paint_viewport.from_node_and_layout_with_viewport(
node,
layout,
0.0,
height.to_double(),
0.0,
) {
Some(node) => node
None => {
self.send_error(
request_id, "unknown error", "Failed to build paint tree for current context",
)
return None
}
}
let t2 = js_now_ms()
let data = @tui_paint_output.raster_to_rgba_base64(paint_node, width, height)
let t3 = js_now_ms()
js_log_stderr(
"capturePaintData: render=" +
(t1 - t0).to_string() +
"ms paint=" +
(t2 - t1).to_string() +
"ms fb=" +
(t3 - t2).to_string() +
"ms total=" +
(t3 - t0).to_string() +
"ms",
)
Some(
make_object({
"width": Json::number(width.to_double()),
"height": Json::number(height.to_double()),
"data": Json::string(data),
}),
)
}
///|
/// Return PaintNode tree as JSON for native paint rendering.
fn BidiProtocol::handle_capture_paint_tree(
self : BidiProtocol,
request_id : Int,
params : Json?,
) -> Unit {
match self.resolve_capture_paint_tree(request_id, params) {
Some(result) => self.send_success(request_id, Some(result))
None => ()
}
}
///|
fn BidiProtocol::resolve_capture_paint_tree(
self : BidiProtocol,
request_id : Int,
params : Json?,
) -> Json? {
let params = match params {
Some(Object(map)) => map
_ => {
self.send_error(
request_id, "invalid argument", "params must be an object",
)
return None
}
}
let ctx_id = match params.get("context") {
Some(String(id)) => id
Some(_) => {
self.send_error(
request_id, "invalid argument", "context must be a string",
)
return None
}
None => {
self.send_error(request_id, "invalid argument", "Missing context")
return None
}
}
let _session = match self.manager.get_session(ctx_id) {
Some(session) => session
None => {
self.send_error(request_id, "no such frame", "Unknown context: " + ctx_id)
return None
}
}
let width = self.resolve_effective_viewport_width(ctx_id)
let height = self.resolve_effective_viewport_height(ctx_id)
let html_json = evaluate_js(serialize_live_document_html_expr())
let html = extract_string_value_from_evaluate_result(html_json)
let ctx = @browser_contract.create_render_context(width, height, false)
let (node, layout) = @renderer.render_to_node_and_layout(html, ctx)
let paint_node = match
@tui_paint_viewport.from_node_and_layout_with_viewport(
node,
layout,
0.0,
height.to_double(),
0.0,
) {
Some(node) => node
None => {
self.send_error(request_id, "unknown error", "Failed to build paint tree")
return None
}
}
let json_str = @tui_paint_output.to_json_string(paint_node)
Some(
make_object({
"width": Json::number(width.to_double()),
"height": Json::number(height.to_double()),
"paintTree": Json::string(json_str),
}),
)
}