///|
pub struct LinkRegion {
x : Double
y : Double
width : Double
height : Double
href : String
}
///|
pub struct RenderResult {
commands : Array[@core.DrawCommand]
links : Array[LinkRegion]
}
///|
/// Render HTML and extract pixel-space link regions.
///
/// Before rendering, every `<a ...>` start tag is rewritten with a unique
/// synthetic `id="__crater_lnk_N"` appended at the end of the tag (so a
/// pre-existing `id` attribute loses per crater's last-wins tokenizer), and
/// its `href` is captured in the same quote-aware scan. The paint tree then
/// carries the synthetic id on each anchor node, so frames and hrefs pair by
/// id — invisible links (`display:none` nodes have zero size; `visibility`
/// hidden nodes fail `should_render`) never shift subsequent pairings.
/// Script bodies and HTML comments are skipped so their contents cannot be
/// mistaken for anchors.
pub fn render_html_document(
html : String,
width : Double,
height : Double,
) -> RenderResult {
// crater 默认用 0.5em/字符的 monospace 近似度量文本宽度,proportional
// 字体(尤其大写/标点)实际更宽,会导致文字溢出布局宽度(被渲染器
// clip 截断或与同行后续内容重叠)。用浏览器校准比例 0.6 让布局宽度
// 更接近实际(换行偏早、行尾留白,但不截断不重叠)。全局设置一次。
@layout_inline.set_builtin_text_advance_ratio(0.6)
let (injected, hrefs) = inject_anchor_ids(html)
let tree = @crater.render_html_to_paint_tree(
injected,
@values.Size::new(width, height),
)
let commands = @translate.paint_node_to_commands(tree)
let links = collect_anchor_links(tree, hrefs)
{ commands, links }
}
///|
/// Scan `<a ...>` start tags: capture `href` and rewrite the tag to append
/// `id="__crater_lnk_N"` in document order. Returns the rewritten HTML and
/// the href list (None for anchors without a parseable href) indexed by the
/// synthetic id suffix.
fn inject_anchor_ids(html : String) -> (String, Array[String?]) {
let hrefs : Array[String?] = []
let buf = StringBuilder()
let len = html.length()
let mut i = 0
let mut n = 0
while i < len {
// Skip HTML comments.
if i + 3 < len &&
html[i] == '<' &&
html[i + 1] == '!' &&
html[i + 2] == '-' &&
html[i + 3] == '-' {
let mut j = i + 4
while j + 2 < len &&
!(html[j] == '-' && html[j + 1] == '-' && html[j + 2] == '>') {
j = j + 1
}
let end = if j + 2 < len { j + 3 } else { len }
buf.write_string(html.unsafe_substring(start=i, end~))
i = end
continue
}
// Skip <script>...</script> bodies.
if is_script_start(html, i) {
let mut j = i
while j + 8 < len {
let c = html[j]
let c2 = html[j + 1]
if (c == '<' || c == '/') &&
(c2 == 's' || c2 == 'S') &&
(html[j + 2] == 'c' || html[j + 2] == 'C') &&
(html[j + 3] == 'r' || html[j + 3] == 'R') &&
(html[j + 4] == 'i' || html[j + 4] == 'I') &&
(html[j + 5] == 'p' || html[j + 5] == 'P') &&
(html[j + 6] == 't' || html[j + 6] == 'T') {
let after = html[j + 7]
if c == '/' &&
(after == ' ' || after == '\t' || after == '\n' || after == '>') {
break
}
}
j = j + 1
}
let mut end = j
while end < len && html[end] != '>' {
end = end + 1
}
end = if end < len { end + 1 } else { len }
buf.write_string(html.unsafe_substring(start=i, end~))
i = end
continue
}
if html[i] == '<' &&
i + 1 < len &&
(html[i + 1] == 'a' || html[i + 1] == 'A') {
let j = i + 2
let next = if j < len { html[j] } else { ' ' }
let is_tag = next == ' ' ||
next == '\t' ||
next == '\n' ||
next == '\r' ||
next == '>' ||
next == '/'
if is_tag {
// Quote-aware scan to the tag end; capture href in the same pass.
let mut k = j
let mut href : String? = None
let mut quote : UInt16 = 0
while k < len {
let c = html[k]
if quote != 0 {
if c == quote {
quote = 0
}
} else if c == '"' || c == '\'' {
quote = c
} else if c == '>' {
break
} else if k + 4 < len &&
(c == 'h' || c == 'H') &&
(html[k + 1] == 'r' || html[k + 1] == 'R') &&
(html[k + 2] == 'e' || html[k + 2] == 'E') &&
(html[k + 3] == 'f' || html[k + 3] == 'F') &&
html[k + 4] == '=' {
let q = k + 5
if q < len && (html[q] == '"' || html[q] == '\'') {
let qq = html[q]
let mut e = q + 1
while e < len && html[e] != qq {
e = e + 1
}
href = Some(html.unsafe_substring(start=q + 1, end=e))
}
}
k = k + 1
}
let end = if k < len { k + 1 } else { k }
let inner_end = if end - 1 < j { j } else { end - 1 }
// Rewrite: original tag (without its closing '>') + synthetic id + '>'.
buf.write_string("<a")
buf.write_string(html.unsafe_substring(start=j, end=inner_end))
buf.write_string(" id=\"__crater_lnk_" + n.to_string() + "\">")
hrefs.push(href)
n = n + 1
i = end
continue
}
}
buf.write_char(html[i].to_int().unsafe_to_char())
i = i + 1
}
(buf.to_string(), hrefs)
}
///|
/// True when `i` points at a `<script` start tag (followed by whitespace, '>' or '/').
fn is_script_start(html : String, i : Int) -> Bool {
if i + 7 >= html.length() || html[i] != '<' {
return false
}
if (html[i + 1] != 's' && html[i + 1] != 'S') ||
(html[i + 2] != 'c' && html[i + 2] != 'C') ||
(html[i + 3] != 'r' && html[i + 3] != 'R') ||
(html[i + 4] != 'i' && html[i + 4] != 'I') ||
(html[i + 5] != 'p' && html[i + 5] != 'P') ||
(html[i + 6] != 't' && html[i + 6] != 'T') {
return false
}
let after = html[i + 7]
after == ' ' ||
after == '\t' ||
after == '\n' ||
after == '\r' ||
after == '>'
}
///|
/// Collect anchor frames from the paint tree (absolute viewport coordinates,
/// visible non-empty nodes only) and pair them with hrefs via the synthetic
/// id suffix. Anchors without a parseable href yield no region.
fn collect_anchor_links(
root : @paint_model.PaintNode,
hrefs : Array[String?],
) -> Array[LinkRegion] {
let out : Array[LinkRegion] = []
fn visit(node : @paint_model.PaintNode, ox : Double, oy : Double) -> Unit {
if !node.paint.should_render() {
return
}
if node.tag == "a" && node.width > 0.0 && node.height > 0.0 {
match anchor_index(node.id) {
Some(index) if index < hrefs.length() =>
match hrefs[index] {
Some(href) =>
out.push({
x: node.x + ox,
y: node.y + oy,
width: node.width,
height: node.height,
href,
})
None => ()
}
_ => ()
}
}
for child in node.children {
visit(child, ox + node.x, oy + node.y)
}
}
visit(root, 0.0, 0.0)
out
}
///|
/// Parse the synthetic anchor id suffix, e.g. "a#__crater_lnk_3" -> 3.
fn anchor_index(id : String) -> Int? {
let marker = "__crater_lnk_"
match id.find(marker) {
None => None
Some(i) => {
let suffix = id.unsafe_substring(
start=i + marker.length(),
end=id.length(),
)
let parsed = Some(@string.parse_int(suffix)) catch { _ => None }
parsed
}
}
}