pub struct HtmlBlock {
text: String,
scale: i32,
bullet: bool,
}
fn tag_name(inner: &str) -> String {
let t = inner.trim().trim_start_matches('/').trim_start();
let end = t
.find(|ch: char| ch.is_whitespace() || ch == '/')
.unwrap_or(t.len());
t[..end].to_ascii_lowercase()
}
pub fn decode_entities(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace(" ", " ")
.replace("&", "&")
}
pub fn collapse_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_space = false;
for ch in s.chars() {
if ch.is_whitespace() {
if !prev_space && !out.is_empty() {
out.push(' ');
}
prev_space = true;
} else {
out.push(ch);
prev_space = false;
}
}
out.trim_end().to_string()
}
fn flush_block(blocks: &mut Vec<HtmlBlock>, cur: &mut String, scale: i32, bullet: bool) {
let text = collapse_ws(&decode_entities(cur));
cur.clear();
if !text.is_empty() {
blocks.push(HtmlBlock { text, scale, bullet });
}
}
pub fn html_to_blocks(src: &str) -> Vec<HtmlBlock> {
let chars: Vec<char> = src.chars().collect();
let mut blocks: Vec<HtmlBlock> = Vec::new();
let mut cur = String::new();
let mut scale: i32 = 1;
let mut bullet = false;
let mut skip_tag: Option<String> = None;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '<' {
let mut j = i + 1;
let mut inner = String::new();
while j < chars.len() && chars[j] != '>' {
inner.push(chars[j]);
j += 1;
}
i = if j < chars.len() { j + 1 } else { j };
let closing = inner.trim_start().starts_with('/');
let name = tag_name(&inner);
if let Some(skip) = skip_tag.clone() {
if closing && name == skip {
skip_tag = None;
}
continue;
}
match name.as_str() {
"script" | "style" | "head" => {
if !closing {
skip_tag = Some(name);
}
}
"br" | "hr" => {
flush_block(&mut blocks, &mut cur, scale, bullet);
bullet = false;
}
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
flush_block(&mut blocks, &mut cur, scale, bullet);
bullet = false;
scale = if closing {
1
} else if name == "h1" {
3
} else {
2
};
}
"li" => {
flush_block(&mut blocks, &mut cur, scale, bullet);
scale = 1;
bullet = !closing;
}
"p" | "div" | "ul" | "ol" | "section" | "article" | "header" | "footer"
| "nav" | "main" | "blockquote" | "pre" | "table" | "tr" | "title" | "body"
| "html" | "figure" | "figcaption" => {
flush_block(&mut blocks, &mut cur, scale, bullet);
bullet = false;
scale = 1;
}
_ => { }
}
continue;
}
if skip_tag.is_some() {
i += 1;
continue;
}
cur.push(c);
i += 1;
}
flush_block(&mut blocks, &mut cur, scale, bullet);
blocks
}
pub fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
let max_chars = max_chars.max(1);
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.chars().count() + 1 + word.chars().count() <= max_chars {
line.push(' ');
line.push_str(word);
} else {
lines.push(std::mem::take(&mut line));
line.push_str(word);
}
while line.chars().count() > max_chars {
let head: String = line.chars().take(max_chars).collect();
let tail: String = line.chars().skip(max_chars).collect();
lines.push(head);
line = tail;
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
pub fn filled_framebuffer(color: (u8, u8, u8), fb_w: i32, fb_h: i32) -> Vec<u8> {
let (r, g, b) = color;
let mut buf = vec![0u8; (fb_w.max(0) as usize) * (fb_h.max(0) as usize) * 4];
let mut i = 0;
while i + 3 < buf.len() {
buf[i] = r;
buf[i + 1] = g;
buf[i + 2] = b;
buf[i + 3] = 255;
i += 4;
}
buf
}
pub fn paint_html_fb(blocks: &[HtmlBlock], fb_w: i32, fb_h: i32) -> Vec<u8> {
let mut buf = filled_framebuffer((13, 13, 13), fb_w, fb_h);
let left = 6i32;
let right = fb_w - 6;
let mut y = 6i32;
for block in blocks {
let scale = block.scale.clamp(1, 3);
let advance = 6 * scale; let line_h = 8 * scale; let max_chars = (((right - left) / advance).max(1)) as usize;
let color = if scale > 1 { (245, 245, 245) } else { (205, 205, 205) };
let text = if block.bullet {
format!("- {}", block.text)
} else {
block.text.clone()
};
for line in wrap_text(&text, max_chars) {
if y + line_h > fb_h {
return buf; }
let mut x = left;
let vp = crate::raster::Viewport::full(fb_w, fb_h);
for ch in line.chars() {
crate::raster::blit_glyph(&mut buf, fb_w, &vp, x, y, ch as u32, color, scale);
x += advance;
}
y += line_h;
}
y += 3; }
buf
}
#[cfg(test)]
mod tests {
use super::*;
fn px(buf: &[u8], w: i32, x: i32, y: i32) -> (u8, u8, u8, u8) {
let i = ((y * w + x) * 4) as usize;
(buf[i], buf[i + 1], buf[i + 2], buf[i + 3])
}
#[test]
fn decode_entities_handles_common_prose() {
assert_eq!(decode_entities("a <b> "c" & d"), "a <b> \"c\" & d");
assert_eq!(decode_entities("&lt;"), "<");
assert_eq!(decode_entities("x y'z'"), "x y'z'");
}
#[test]
fn collapse_ws_squeezes_and_trims() {
assert_eq!(collapse_ws(" a \n\t b c "), "a b c");
assert_eq!(collapse_ws(""), "");
assert_eq!(collapse_ws(" \n "), "");
}
#[test]
fn html_to_blocks_extracts_headings_lists_and_skips_head() {
let src = "<html><head><title>skip me</title><style>.x{}</style></head>\
<body><h1>Title</h1><p>Body & text</p>\
<ul><li>one</li><li>two</li></ul>\
<script>var hidden = 1;</script></body></html>";
let blocks = html_to_blocks(src);
let flat: Vec<(&str, i32, bool)> =
blocks.iter().map(|b| (b.text.as_str(), b.scale, b.bullet)).collect();
assert_eq!(
flat,
vec![
("Title", 3, false),
("Body & text", 1, false),
("one", 1, true),
("two", 1, true),
]
);
}
#[test]
fn html_to_blocks_flows_inline_tags_into_one_block() {
let blocks = html_to_blocks("<p>a <b>bold</b> and <a href=\"#\">link</a></p>");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "a bold and link");
}
#[test]
fn wrap_text_wraps_and_hard_breaks() {
assert_eq!(wrap_text("aa bb cc", 5), vec!["aa bb", "cc"]);
assert_eq!(wrap_text("abcdefgh", 3), vec!["abc", "def", "gh"]);
assert_eq!(wrap_text("ab", 0), vec!["a", "b"]);
assert!(wrap_text("", 10).is_empty());
}
#[test]
fn filled_framebuffer_is_opaque_and_sized() {
let buf = filled_framebuffer((7, 8, 9), 4, 3);
assert_eq!(buf.len(), 4 * 3 * 4);
for p in buf.chunks_exact(4) {
assert_eq!(p, &[7, 8, 9, 255]);
}
}
#[test]
fn paint_html_fb_paints_text_pixels_on_dark_ground() {
let w = 64;
let h = 64;
let buf = paint_html_fb(&html_to_blocks("<p>HI</p>"), w, h);
assert_eq!(buf.len(), (w * h * 4) as usize);
assert_eq!(px(&buf, w, w - 1, h - 1), (13, 13, 13, 255));
let lit = buf.chunks_exact(4).any(|p| p[0] == 205 && p[1] == 205 && p[2] == 205);
assert!(lit, "expected at least one glyph pixel");
}
#[test]
fn paint_html_fb_clips_at_bottom_instead_of_panicking() {
let blocks = html_to_blocks("<p>abc</p><p>def</p>");
let buf = paint_html_fb(&blocks, 32, 8);
assert_eq!(buf.len(), 32 * 8 * 4);
}
}