///|
/// Text measurement constants (monospace font simulation)
/// Character width ratio for monospace fonts (width / font-size)
/// Typical monospace fonts have width:height ratio around 0.5-0.55
let char_width_ratio : Double = 0.5 // Conservative ratio for text fitting
///|
let chars_per_line : Int = 80 // Default wrap width in characters (single-width)
///|
/// Ahem/fallback-font-like glyph width ratio observed in WPT harness rendering.
let large_ahem_like_char_width_ratio : Double = 0.722
///|
/// UA default "normal" line-height approximation used for table internals.
let normal_line_height_ratio : Double = 1.2
///|
fn is_apple_system_font_family(font_family : String) -> Bool {
let family = font_family.to_lower()
family == "-apple-system" || family == "blinkmacsystemfont"
}
///|
fn apple_system_normal_line_height(font_size : Double) -> Double {
let rounded = font_size.round()
if (font_size - rounded).abs() < 0.01 {
match rounded.to_int() {
10 => return 12.0
11 => return 13.0
12 => return 15.0
13 => return 16.0
14 => return 17.0
15 => return 18.0
16 => return 18.0
17 => return 20.0
18 => return 21.0
20 => return 23.0
24 => return 28.0
32 => return 38.0
_ => ()
}
}
(font_size * 1.17).round()
}
///|
fn inherited_line_height_looks_like_apple_normal(
parent_style : @style.Style?,
) -> Bool {
match parent_style {
Some(parent) =>
(
is_apple_system_font_family(parent.font_family) &&
(parent.line_height - apple_system_normal_line_height(parent.font_size)).abs() <
0.01
) ||
(parent.line_height - parent.font_size * normal_line_height_ratio).abs() <
0.01
None => true
}
}
///|
fn apply_browser_normal_line_height_if_needed(
style : @style.Style,
parent_style : @style.Style?,
author_line_height_present : Bool,
) -> @style.Style {
if author_line_height_present ||
!is_apple_system_font_family(style.font_family) ||
!inherited_line_height_looks_like_apple_normal(parent_style) {
style
} else {
{ ..style, line_height: apple_system_normal_line_height(style.font_size) }
}
}
///|
/// Current cellpadding value from the nearest ancestor .
/// -1.0 means not set. Updated when entering/leaving table elements.
let current_cellpadding : Ref[Double] = { val: -1.0 }
///|
/// 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)
}
///|
/// Calculate display width of a character (1 for narrow, 2 for wide)
fn char_display_width(c : Char) -> Int {
let cp = c.to_int()
// U+2003 EM SPACE should advance by one full em in monospace-like metrics.
if cp == 0x2003 {
2
} else if is_wide_char(c) {
2
} else {
1
}
}
///|
/// Place a word into wrapped lines.
/// Returns (updated_lines, updated_line_width).
fn place_word_with_wrap(
lines : Int,
line_width : Int,
word_width : Int,
cols_available : Int,
needs_space : Bool,
) -> (Int, Int) {
if cols_available <= 0 {
return (lines, line_width)
}
let mut new_lines = lines
let mut new_line_width = line_width
let mut space_width = 0
if needs_space && new_line_width > 0 {
space_width = 1
}
// Fits current line
if new_line_width > 0 &&
new_line_width + space_width + word_width <= cols_available {
new_line_width = new_line_width + space_width + word_width
return (new_lines, new_line_width)
}
// Move to next line if current line already has content.
if new_line_width > 0 {
new_lines = new_lines + 1
new_line_width = 0
}
// Fits a fresh line.
if word_width <= cols_available {
return (new_lines, word_width)
}
// Long word fallback: hard-wrap by available columns.
let full_lines = word_width / cols_available
let remainder = word_width % cols_available
if remainder == 0 {
if full_lines > 0 {
new_lines = new_lines + full_lines - 1
new_line_width = cols_available
} else {
new_line_width = 0
}
} else {
new_lines = new_lines + full_lines
new_line_width = remainder
}
(new_lines, new_line_width)
}
///|
/// Render context with viewport dimensions
pub(all) struct RenderContext {
viewport_width : Double
viewport_height : Double
root_font_size : Double
color_scheme : @css.ColorScheme
}
///|
pub fn RenderContext::default() -> RenderContext {
{
viewport_width: 800.0,
viewport_height: 600.0,
root_font_size: 16.0,
color_scheme: @css.ColorScheme::Light,
}
}
///|
/// Text metrics provider hook for integrating external font engines.
pub struct TextMetricsProvider {
func : (
String,
Double,
Double,
@style.WhiteSpace,
@style.WritingMode,
Double,
String,
) -> @layout_types.MeasureFunc
}
///|
pub fn TextMetricsProvider::new(
func : (
String,
Double,
Double,
@style.WhiteSpace,
@style.WritingMode,
Double,
String,
) -> @layout_types.MeasureFunc,
) -> TextMetricsProvider {
{ func, }
}
///|
let text_metrics_provider_override : Ref[TextMetricsProvider?] = { val: None }
///|
pub fn set_text_metrics_provider(provider : TextMetricsProvider) -> Unit {
text_metrics_provider_override.val = Some(provider)
}
///|
pub fn clear_text_metrics_provider() -> Unit {
text_metrics_provider_override.val = None
}
///|
/// Force monospace text metrics regardless of TextMetricsProvider.
/// Use this for TUI/terminal rendering where proportional metrics are meaningless.
let force_monospace_metrics_flag : Ref[Bool] = { val: false }
///|
pub fn set_force_monospace_metrics(enabled : Bool) -> Unit {
force_monospace_metrics_flag.val = enabled
}
///|
pub fn get_force_monospace_metrics() -> Bool {
force_monospace_metrics_flag.val
}
///|
#cfg(target="js")
extern "js" fn is_perf_logging_enabled() -> Bool =
#|() => {
#| try {
#| return typeof process !== "undefined" &&
#| process != null &&
#| process.env != null &&
#| process.env.CRATER_RENDERER_PERF_LOG === "1";
#| } catch {
#| return false;
#| }
#|}
///|
#cfg(not(target="js"))
fn is_perf_logging_enabled() -> Bool {
false
}
///|
fn maybe_log_perf(message : String) -> Unit {
if is_perf_logging_enabled() {
println(message)
}
}
///|
#cfg(target="js")
extern "js" fn get_builtin_text_advance_ratio_override() -> Double =
#|() => {
#| const ratio = Number(globalThis.__craterBuiltinTextAdvanceRatio);
#| return Number.isFinite(ratio) ? ratio : -1;
#|}
///|
/// Test-only hook for deterministic built-in text measurement on JS.
#cfg(target="js")
pub extern "js" fn set_builtin_text_advance_ratio_override_for_test(
ratio : Double,
) -> Unit =
#|(ratio) => {
#| globalThis.__craterBuiltinTextAdvanceRatio = ratio;
#|}
///|
/// Clear the deterministic built-in text measurement hook on JS.
#cfg(target="js")
pub extern "js" fn clear_builtin_text_advance_ratio_override_for_test() -> Unit =
#|() => {
#| delete globalThis.__craterBuiltinTextAdvanceRatio;
#|}
///|
#cfg(not(target="js"))
let builtin_text_advance_ratio_override : Ref[Double] = { val: -1.0 }
///|
/// Test-only hook for deterministic built-in text measurement.
#cfg(not(target="js"))
pub fn set_builtin_text_advance_ratio_override_for_test(ratio : Double) -> Unit {
builtin_text_advance_ratio_override.val = ratio
}
///|
/// Clear the deterministic built-in text measurement hook.
#cfg(not(target="js"))
pub fn clear_builtin_text_advance_ratio_override_for_test() -> Unit {
builtin_text_advance_ratio_override.val = -1.0
}
///|
#cfg(not(target="js"))
fn get_builtin_text_advance_ratio_override() -> Double {
builtin_text_advance_ratio_override.val
}
///|
/// Image intrinsic-size provider hook for integrating external image metadata resolvers.
pub struct ImageIntrinsicSizeProvider {
func : (String) -> (Double, Double)?
}
///|
pub fn ImageIntrinsicSizeProvider::new(
func : (String) -> (Double, Double)?,
) -> ImageIntrinsicSizeProvider {
{ func, }
}
///|
let image_intrinsic_size_provider_override : Ref[ImageIntrinsicSizeProvider?] = {
val: None,
}
///|
pub fn set_image_intrinsic_size_provider(
provider : ImageIntrinsicSizeProvider,
) -> Unit {
image_intrinsic_size_provider_override.val = Some(provider)
}
///|
pub fn clear_image_intrinsic_size_provider() -> Unit {
image_intrinsic_size_provider_override.val = None
}
///|
fn create_text_measure_with_provider(
text : String,
font_size : Double,
text_line_height : Double,
white_space : @style.WhiteSpace,
writing_mode : @style.WritingMode,
font_weight? : Double = 400.0,
font_family? : String = "",
letter_spacing? : Double = 0.0,
word_spacing? : Double = 0.0,
) -> @layout_types.MeasureFunc {
fn white_space_collapses_edges(white_space : @style.WhiteSpace) -> Bool {
match white_space {
@style.WhiteSpace::Normal
| @style.WhiteSpace::Nowrap
| @style.WhiteSpace::PreLine => true
_ => false
}
}
fn has_boundary_collapsible_whitespace(text : String) -> Bool {
let mut starts_with_ws = false
let mut seen_first = false
let mut ends_with_ws = false
for c in text.iter() {
if !seen_first {
seen_first = true
starts_with_ws = c == ' ' ||
c == '\t' ||
c == '\n' ||
c == '\r' ||
c == '\u000C'
}
ends_with_ws = c == ' ' ||
c == '\t' ||
c == '\n' ||
c == '\r' ||
c == '\u000C'
}
starts_with_ws || ends_with_ws
}
fn has_non_collapsible_space_glyph(text : String) -> Bool {
for c in text.iter() {
let cp = c.to_int()
if cp == 0x00A0 || // NO-BREAK SPACE
cp == 0x1680 ||
(cp >= 0x2000 && cp <= 0x200A) ||
cp == 0x202F ||
cp == 0x205F ||
cp == 0x3000 {
return true
}
}
false
}
fn is_ahem_like_measure_target(
text : String,
font_size : Double,
text_line_height : Double,
) -> Bool {
if font_size < 80.0 || text_line_height > font_size * 1.01 {
return false
}
let trimmed = trim_collapsible_whitespace_edges(text)
if trimmed.is_empty() {
return false
}
for c in trimmed.iter() {
if c != 'X' && c != '\u00A0' {
return false
}
}
true
}
let trimmed = trim_collapsible_whitespace_edges(text)
let force_builtin_measure = white_space_collapses_edges(white_space) &&
!text.is_empty() &&
(
trimmed.is_empty() ||
has_boundary_collapsible_whitespace(text) ||
has_non_collapsible_space_glyph(text) ||
is_ahem_like_measure_target(text, font_size, text_line_height)
)
if force_monospace_metrics_flag.val {
return create_text_measure(
text,
font_size,
text_line_height,
white_space,
writing_mode,
font_family~,
)
}
let base_measure = match text_metrics_provider_override.val {
Some(provider) =>
if force_builtin_measure {
create_text_measure(
text,
font_size,
text_line_height,
white_space,
writing_mode,
font_family~,
)
} else {
(provider.func)(
text, font_size, text_line_height, white_space, writing_mode, font_weight,
font_family,
)
}
None =>
create_text_measure(
text,
font_size,
text_line_height,
white_space,
writing_mode,
font_family~,
)
}
// Apply letter-spacing and word-spacing adjustments
let adjusted : @layout_types.MeasureFunc = if letter_spacing != 0.0 ||
word_spacing != 0.0 {
let char_count = text.iter().count()
let space_count = text
.iter()
.fold(init=0, fn(acc, c) { if c == ' ' { acc + 1 } else { acc } })
let extra_width = letter_spacing * (char_count - 1).to_double() +
word_spacing * space_count.to_double()
let base_fn = base_measure.func
{
func: fn(available_width, available_height) {
let result = base_fn(available_width, available_height)
{
..result,
min_width: result.min_width + extra_width,
max_width: result.max_width + extra_width,
}
},
}
} else {
base_measure
}
let cache : Array[(Double, Double, @layout_types.IntrinsicSize)] = []
let memoized : @layout_types.MeasureFunc = {
func: fn(available_width, available_height) {
for entry in cache {
let (caw, cah, cached) = entry
if caw == available_width && cah == available_height {
return cached
}
}
let result = (adjusted.func)(available_width, available_height)
if cache.length() < 32 {
cache.push((available_width, available_height, result))
}
result
},
}
// Route the result through the record/replay layer (a no-op unless recording
// or replaying the external measurement side effect is active).
let prefix = measurement_key_prefix(
text, font_size, text_line_height, white_space, writing_mode, font_weight, font_family,
)
record_replay_text_measure(prefix, memoized)
}
///|
/// Create a MeasureFunc for text content with font metrics
/// Calculates text dimensions based on monospace character width
/// Accounts for East Asian Wide characters (CJK) which take 2 columns
/// Handles explicit line breaks (from
elements converted to \n)
/// Supports writing-mode for vertical text layout
fn create_text_measure(
text : String,
font_size : Double,
text_line_height : Double,
white_space : @style.WhiteSpace,
writing_mode : @style.WritingMode,
font_family? : String = "",
) -> @layout_types.MeasureFunc {
let raw_trimmed = trim_collapsible_whitespace_edges(text)
let mut trimmed = if raw_trimmed.is_empty() && !text.is_empty() {
" "
} else {
raw_trimmed.to_string()
}
if !raw_trimmed.is_empty() {
let mut starts_with_ws = false
let mut seen_first = false
let mut ends_with_ws = false
let mut before_first_non_ws = true
let mut leading_has_line_break = false
for c in text.iter() {
if !seen_first {
seen_first = true
starts_with_ws = c == ' ' ||
c == '\t' ||
c == '\n' ||
c == '\r' ||
c == '\u000C'
}
if before_first_non_ws {
if c == '\n' || c == '\r' {
leading_has_line_break = true
}
let is_ws = c == ' ' ||
c == '\t' ||
c == '\n' ||
c == '\r' ||
c == '\u000C'
if !is_ws {
before_first_non_ws = false
}
}
ends_with_ws = c == ' ' ||
c == '\t' ||
c == '\n' ||
c == '\r' ||
c == '\u000C'
}
if starts_with_ws && !leading_has_line_break {
trimmed = " " + trimmed
}
if ends_with_ws {
trimmed = trimmed + " "
}
}
let override_ratio = get_builtin_text_advance_ratio_override()
let mut effective_char_width_ratio = if override_ratio > 0.0 {
override_ratio
} else if font_family.to_lower().contains("ahem") {
1.0
} else {
char_width_ratio
}
if font_size >= 80.0 && text_line_height <= font_size * 1.01 {
let mut all_ahem_like = true
for c in trimmed.iter() {
if c != 'X' && c != '\u00A0' {
all_ahem_like = false
break
}
}
if all_ahem_like && !trimmed.is_empty() {
effective_char_width_ratio = large_ahem_like_char_width_ratio
}
}
let char_width = font_size * effective_char_width_ratio
// Split by explicit line breaks and calculate per-line metrics
let mut explicit_lines = 1
let mut max_line_width = 0
let mut current_line_width = 0
let mut max_word_width = 0
let mut current_word_width = 0
for c in trimmed.iter() {
if c == '\n' {
// Explicit line break (from
)
explicit_lines = explicit_lines + 1
if current_line_width > max_line_width {
max_line_width = current_line_width
}
if current_word_width > max_word_width {
max_word_width = current_word_width
}
current_line_width = 0
current_word_width = 0
} else if c == ' ' || c == '\t' {
if current_word_width > max_word_width {
max_word_width = current_word_width
}
current_word_width = 0
current_line_width = current_line_width + 1 // Space width
} else {
let cw = char_display_width(c)
current_line_width = current_line_width + cw
current_word_width = current_word_width + cw
}
}
// Handle last line/word
if current_line_width > max_line_width {
max_line_width = current_line_width
}
if current_word_width > max_word_width {
max_word_width = current_word_width
}
// Check if vertical writing mode
let is_vertical = writing_mode.is_vertical()
// max_width is the widest line (for intrinsic sizing)
let text_max_width = max_line_width.to_double() * char_width
// min_width is the longest word
let text_min_width = max_word_width.to_double() * char_width
// min_height accounts for explicit line breaks
let min_lines = explicit_lines
{
func: fn(
available_width : Double,
available_height : Double,
) -> @layout_types.IntrinsicSize {
// For white-space: nowrap, don't wrap text
let no_wrap = match white_space {
@style.WhiteSpace::Nowrap => true
_ => false
}
// In vertical mode, the inline axis is vertical (height constrains)
// In horizontal mode, the inline axis is horizontal (width constrains)
let (available_inline, line_size) = if is_vertical {
(available_height, char_width)
} else {
(available_width, char_width)
}
// Calculate how many single-width columns fit in available inline space
// For nowrap, use a very large number to prevent wrapping
let cols_available = if no_wrap {
1000000 // Large number to prevent wrapping
} else if available_inline > 0.0 {
(available_inline / line_size).to_int()
} else {
chars_per_line
}
// Calculate total lines needed (considering both wrapping and explicit breaks)
// For each explicit line, calculate how many visual lines it needs
let use_word_wrap = match white_space {
@style.WhiteSpace::Normal | @style.WhiteSpace::PreLine => true
_ => false
}
let mut total_lines = 1
if use_word_wrap && !no_wrap {
// Greedy word wrapping with long-word hard-wrap fallback.
let mut current_line_width = 0
let mut current_word_width = 0
let mut has_pending_space = false
for c in trimmed.iter() {
if c == '\n' {
if current_word_width > 0 {
let (l, w) = place_word_with_wrap(
total_lines, current_line_width, current_word_width, cols_available,
has_pending_space,
)
total_lines = l
current_line_width = w
current_word_width = 0
}
// Explicit line break always starts a new visual line.
total_lines = total_lines + 1
current_line_width = 0
has_pending_space = false
} else if c == ' ' || c == '\t' {
if current_word_width > 0 {
let (l, w) = place_word_with_wrap(
total_lines, current_line_width, current_word_width, cols_available,
has_pending_space,
)
total_lines = l
current_line_width = w
current_word_width = 0
}
has_pending_space = true
} else {
current_word_width = current_word_width + char_display_width(c)
}
}
if current_word_width > 0 {
let (l, _w) = place_word_with_wrap(
total_lines, current_line_width, current_word_width, cols_available,
has_pending_space,
)
total_lines = l
}
} else {
// Character-based wrapping for nowrap/pre/pre-wrap behavior.
let mut current_line_width = 0
total_lines = 0
for c in trimmed.iter() {
if c == '\n' {
// End of explicit line
let line_visual_lines = if cols_available > 0 &&
current_line_width > 0 {
(current_line_width + cols_available - 1) / cols_available
} else {
1
}
total_lines = total_lines + line_visual_lines
current_line_width = 0
} else if c == ' ' || c == '\t' {
current_line_width = current_line_width + 1
} else {
current_line_width = current_line_width + char_display_width(c)
}
}
// Handle last line
let last_line_visual = if cols_available > 0 && current_line_width > 0 {
(current_line_width + cols_available - 1) / cols_available
} else {
1
}
total_lines = total_lines + last_line_visual
}
// In vertical mode, swap width/height concepts
// - Width = number of lines (block dimension)
// - Height = characters per line (inline dimension)
if is_vertical {
let width = total_lines.to_double() * text_line_height
let max_wrapped_height = if no_wrap {
text_max_width
} else if available_inline > 0.0 {
let wrapped_height = cols_available.to_double() * char_width
if text_max_width < wrapped_height {
text_max_width
} else {
wrapped_height
}
} else {
text_max_width
}
let min_width = min_lines.to_double() * text_line_height
{
min_width,
max_width: width,
min_height: text_min_width, // word length becomes height
max_height: max_wrapped_height,
}
} else {
let height = total_lines.to_double() * text_line_height
let min_height = min_lines.to_double() * text_line_height
{
min_width: text_min_width,
max_width: text_max_width,
min_height,
max_height: height,
}
}
},
}
}