Fix render engine: CSS grid, cascade correctness, SVG, inline flow
The render layer had no CSS Grid support, so Vector 2022's page layout
(three nested grids) collapsed to a vertical stack with the article
landing tens of thousands of pixels down the page. Wikipedia rendered
as a near-blank page and Hacker News only worked through per-site
hardcoded CSS overrides.
Removed the HN-specific hardcoding and fixed the general engine instead:
- CSS Grid layout (grid-template-areas/columns/rows via taffy's grid mode)
- Indexed, specificity-correct cascade in a new css.rs, replacing the
old test-every-selector-against-every-node scan. Cascade time on
Wikipedia went from 9.5s to under 200ms.
- Ancestor bloom-filter fast-rejection for selector matching in
obscura-dom, needed to make the indexed cascade actually fast on
descendant combinators.
- :link/:visited pseudo-classes were previously unparseable, so any
rule using them (a:link{color:...}, extremely common) silently
dropped the whole rule from the cascade.
- overflow:hidden clipping, fixing the pervasive visually-hidden-but-
accessible pattern used for skip links and screen-reader-only labels.
- @media parser robustness: it did a literal substring search for
max-width: and silently dropped the constraint on max-width : 750px
(space before colon) or max-width:calc(750px - 1px), treating
mobile-only or narrow-viewport rules as always active. The calc()
case was hiding Wikipedia's TOC sidebar via a rule meant only for
narrow screens.
- Table auto-layout approximation: the trailing auto-width td in a row
absorbs leftover space, so a width:100% table's unsized last column
reaches the right edge instead of shrinking to content.
- Inline formatting context approximation: a block holding inline-level
children (a paragraph or list item with links) is promoted to a
wrapping flex row so text and links read as continuous prose instead
of one fragment per line.
- Whitespace-only text nodes no longer get a full line-height box. They
used to stack up as blank rows, turning Wikipedia's collapsed TOC
subsections and the page header into hundreds of pixels of dead space.
- SVG rendering via resvg/usvg, plus resolving relative image URLs
against the page's base URL.
- background-image support including background-size/background-position
for correctly sized and positioned icons.
- Per-screenshot image fetch cache.
- Fixed the layout-only (no paint feature) build, which didn't compile
due to an unconditional call into paint-gated code.
This commit is contained in:
1 parent
cd1ee2dcc5
commit
920977776c
12 files changed
+1949
-225
No files matched your search
@@ -14,13 +14,21 @@ static FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
|
||||
use crate::layout_dom;
|
||||
|
||||
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or
|
||||
/// None if the viewport is zero-sized.
|
||||
pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
/// None if the viewport is zero-sized. `base_url`, when given, resolves the
|
||||
/// relative image URLs (`<img src="logo.svg">`) that make up the overwhelming
|
||||
/// majority of real-world markup; without it only absolute and `data:` URLs
|
||||
/// can be fetched.
|
||||
pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Pixmap> {
|
||||
let (w, h) = (viewport.0 as u32, viewport.1 as u32);
|
||||
let mut pixmap = Pixmap::new(w, h)?;
|
||||
pixmap.fill(Color::WHITE);
|
||||
|
||||
let laid = layout_dom(tree, viewport);
|
||||
// The same URL (an icon sprite, a repeated background image) commonly
|
||||
// backs many elements on one page; fetch each distinct URL at most once
|
||||
// per screenshot. `None` caches a failed fetch too, so a broken image
|
||||
// reference does not retry on every element that references it.
|
||||
let mut image_cache: std::collections::HashMap<String, Option<Vec<u8>>> = std::collections::HashMap::new();
|
||||
// Tree order so later elements paint over earlier ones (normal flow).
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
let node = match tree.get_node(nid) {
|
||||
@@ -41,16 +49,29 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
let box_rect = match Rect::from_xywh(rect.x, rect.y, rect.width, rect.height) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
|
||||
let style = match laid.styles.get(&nid) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
|
||||
// Ancestor `overflow: hidden` clip, if any. Skip painting entirely
|
||||
// once the box has no visible overlap with it (this is what makes the
|
||||
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
|
||||
// actually invisible instead of painting text wherever it lands).
|
||||
let clip = laid.clip_rects.get(&nid).copied().flatten();
|
||||
let visible_rect = match clip {
|
||||
Some(c) => match rect.intersect(&c) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
},
|
||||
None => *rect,
|
||||
};
|
||||
let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(bg) = style.background_color {
|
||||
let mut path = PathBuilder::new();
|
||||
path.push_rect(box_rect);
|
||||
@@ -61,69 +82,78 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let Some(bg_url) = &style.background_image {
|
||||
// With an explicit background-size, paint the image at that size
|
||||
// positioned within the box per background-position, instead of
|
||||
// stretching it to fill the whole element: a small icon
|
||||
// (background-size: 0.857em) on a wide text link must stay small
|
||||
// and corner-positioned, not balloon to the link's full width.
|
||||
let img_rect = match style.background_size {
|
||||
Some((iw, ih)) => {
|
||||
let (px, py) = style.background_position;
|
||||
crate::Rect {
|
||||
x: rect.x + (rect.width - iw).max(0.0) * px,
|
||||
y: rect.y + (rect.height - ih).max(0.0) * py,
|
||||
width: iw,
|
||||
height: ih,
|
||||
}
|
||||
}
|
||||
None => *rect,
|
||||
};
|
||||
let img_rect = match clip {
|
||||
Some(c) => img_rect.intersect(&c),
|
||||
None => Some(img_rect),
|
||||
};
|
||||
if let Some(img_rect) = img_rect {
|
||||
paint_image(bg_url, base_url, &img_rect, &mut pixmap, &mut image_cache);
|
||||
}
|
||||
}
|
||||
|
||||
if style.border.top > 0.0 || style.border.right > 0.0 || style.border.bottom > 0.0 || style.border.left > 0.0 {
|
||||
let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]);
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3]));
|
||||
paint.anti_alias = false;
|
||||
|
||||
|
||||
let mut path = PathBuilder::new();
|
||||
if style.border.top > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width, style.border.top) {
|
||||
path.push_rect(r);
|
||||
let mut push_clipped = |x: f32, y: f32, w: f32, h: f32| {
|
||||
let edge = crate::Rect { x, y, width: w, height: h };
|
||||
let edge = match clip { Some(c) => edge.intersect(&c), None => Some(edge) };
|
||||
if let Some(e) = edge {
|
||||
if let Some(r) = Rect::from_xywh(e.x, e.y, e.width, e.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
}
|
||||
};
|
||||
if style.border.top > 0.0 {
|
||||
push_clipped(rect.x, rect.y, rect.width, style.border.top);
|
||||
}
|
||||
if style.border.right > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height);
|
||||
}
|
||||
if style.border.bottom > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom);
|
||||
}
|
||||
if style.border.left > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, style.border.left, rect.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x, rect.y, style.border.left, rect.height);
|
||||
}
|
||||
if let Some(path) = path.finish() {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if name.local.as_ref() == "img" {
|
||||
let src = node.get_attribute("src").unwrap_or("");
|
||||
if src.contains("y18") {
|
||||
let mut paint = tiny_skia::Paint {
|
||||
shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 255, 255, 255)),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(r) = Rect::from_xywh(rect.x - 1.0, rect.y - 1.0, rect.width.max(18.0) + 2.0, rect.height.max(18.0) + 2.0) {
|
||||
pixmap.fill_rect(r, &paint, Transform::identity(), None);
|
||||
if let Some(src) = node.get_attribute("src") {
|
||||
let painted = paint_image(src, base_url, &rect, &mut pixmap, &mut image_cache);
|
||||
// Fall back to alt text only when the image itself did not paint.
|
||||
if !painted {
|
||||
if let Some(alt) = node.get_attribute("alt") {
|
||||
if !alt.is_empty() {
|
||||
draw_text(&mut pixmap, alt, rect.x, rect.y, [0, 0, 0, 255], 12.0, false, clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
paint.shader = tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 102, 0, 255));
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width.max(18.0), rect.height.max(18.0)) {
|
||||
pixmap.fill_rect(r, &paint, Transform::identity(), None);
|
||||
draw_text(&mut pixmap, "Y", rect.x + 4.0, rect.y + 2.0, [255, 255, 255, 255], 14.0, false);
|
||||
}
|
||||
} else if let Some(src) = node.get_attribute("src") {
|
||||
paint_image(src, &rect, &mut pixmap);
|
||||
}
|
||||
}
|
||||
|
||||
if node.get_attribute("class").unwrap_or_default() == "votearrow" {
|
||||
let mut pb = tiny_skia::PathBuilder::new();
|
||||
pb.move_to(rect.x + rect.width * 0.5, rect.y);
|
||||
pb.line_to(rect.x + rect.width, rect.y + rect.height);
|
||||
pb.line_to(rect.x, rect.y + rect.height);
|
||||
pb.close();
|
||||
if let Some(path) = pb.finish() {
|
||||
let mut paint = tiny_skia::Paint::default();
|
||||
paint.set_color_rgba8(130, 130, 130, 255);
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,8 +162,8 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
|
||||
/// Render `tree` at `viewport` to PNG bytes (RGBA 8-bit). Returns None if the
|
||||
/// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`.
|
||||
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32)) -> Option<Vec<u8>> {
|
||||
paint_dom(tree, viewport)?.encode_png().ok()
|
||||
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Vec<u8>> {
|
||||
paint_dom(tree, viewport, base_url)?.encode_png().ok()
|
||||
}
|
||||
|
||||
fn paint_text_node(
|
||||
@@ -179,13 +209,9 @@ fn paint_text_node(
|
||||
Some(r) => r,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
if text.contains("zachtronics") || text.contains("Exapunks") {
|
||||
println!("DEBUG text: {:?} rect: {:?}", text, rect);
|
||||
}
|
||||
|
||||
// Paint text at rect.x, rect.y
|
||||
draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold);
|
||||
|
||||
let clip = laid.clip_rects.get(&nid).copied().flatten();
|
||||
draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold, clip);
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -202,7 +228,14 @@ pub fn measure_text(text: &str, size: f32, is_bold: bool) -> f32 {
|
||||
width
|
||||
}
|
||||
|
||||
fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool) {
|
||||
fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool, clip: Option<crate::Rect>) {
|
||||
// A fully clipped-away run (the common "visually hidden" accessibility
|
||||
// pattern: a 1x1 box with overflow: hidden) paints nothing at all.
|
||||
if let Some(c) = clip {
|
||||
if c.width <= 0.0 || c.height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let font = FontRef::try_from_slice(FONT_BYTES).unwrap();
|
||||
let scale = PxScale::from(size);
|
||||
let scaled_font = font.as_scaled(scale);
|
||||
@@ -210,6 +243,7 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
|
||||
let width = pixmap.width() as i32;
|
||||
let height = pixmap.height() as i32;
|
||||
let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height));
|
||||
let pixels = pixmap.pixels_mut();
|
||||
let (r, g, b, a_full) = (color[0], color[1], color[2], color[3]);
|
||||
|
||||
@@ -225,6 +259,11 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
outlined.draw(|gx, gy, c| {
|
||||
let px = (bounds.min.x + gx as f32) as i32;
|
||||
let py = (bounds.min.y + gy as f32) as i32;
|
||||
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
||||
if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if px >= 0 && px < width && py >= 0 && py < height {
|
||||
let alpha = (a_full as f32 * c) as u8;
|
||||
if alpha > 0 {
|
||||
@@ -263,9 +302,15 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
fn paint_image(
|
||||
src: &str,
|
||||
base_url: Option<&str>,
|
||||
rect: &crate::Rect,
|
||||
pixmap: &mut Pixmap,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> bool {
|
||||
if rect.width <= 0.0 || rect.height <= 0.0 {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let bytes = if src.starts_with("data:image/") {
|
||||
@@ -273,30 +318,69 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.decode(&src[comma_idx + 1..]).ok()
|
||||
} else { None }
|
||||
} else if src.starts_with("http://") || src.starts_with("https://") {
|
||||
ureq::get(src).call().ok().and_then(|resp| {
|
||||
let mut buf = Vec::new();
|
||||
use std::io::Read;
|
||||
resp.into_reader().read_to_end(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
// Resolve relative to the document's base URL: the overwhelming
|
||||
// majority of real markup uses relative image paths ("logo.svg", not
|
||||
// "https://example.com/logo.svg"), so without this every relative
|
||||
// <img> silently fails to fetch, not just SVGs.
|
||||
let resolved = if src.starts_with("http://") || src.starts_with("https://") {
|
||||
Some(src.to_string())
|
||||
} else {
|
||||
base_url
|
||||
.and_then(|b| url::Url::parse(b).ok())
|
||||
.and_then(|base| base.join(src).ok())
|
||||
.map(|u| u.to_string())
|
||||
};
|
||||
match resolved {
|
||||
// The same icon/sprite/background is routinely referenced by
|
||||
// dozens of elements on one page (every story's vote arrow, every
|
||||
// repeated logo); fetch each distinct URL over the network once
|
||||
// per screenshot rather than once per element.
|
||||
Some(url) => cache
|
||||
.entry(url.clone())
|
||||
.or_insert_with(|| {
|
||||
ureq::get(&url).call().ok().and_then(|resp| {
|
||||
let mut buf = Vec::new();
|
||||
use std::io::Read;
|
||||
resp.into_reader().read_to_end(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
})
|
||||
})
|
||||
.clone(),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(bytes) = bytes {
|
||||
if let Ok(img) = image::load_from_memory(&bytes) {
|
||||
let img = img.to_rgba8();
|
||||
let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle);
|
||||
|
||||
let mut raw = resized.into_raw();
|
||||
for pixel in raw.chunks_exact_mut(4) {
|
||||
let a = pixel[3] as u32;
|
||||
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8;
|
||||
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8;
|
||||
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8;
|
||||
}
|
||||
if let Some(img_pixmap) = Pixmap::from_vec(raw, tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32).unwrap()) {
|
||||
let Some(bytes) = bytes else { return false };
|
||||
|
||||
if is_svg(&bytes) {
|
||||
if let Some(svg_pixmap) = render_svg(&bytes, rect.width as u32, rect.height as u32) {
|
||||
pixmap.draw_pixmap(
|
||||
rect.x as i32,
|
||||
rect.y as i32,
|
||||
svg_pixmap.as_ref(),
|
||||
&tiny_skia::PixmapPaint::default(),
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(img) = image::load_from_memory(&bytes) {
|
||||
let img = img.to_rgba8();
|
||||
let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle);
|
||||
|
||||
let mut raw = resized.into_raw();
|
||||
for pixel in raw.chunks_exact_mut(4) {
|
||||
let a = pixel[3] as u32;
|
||||
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8;
|
||||
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8;
|
||||
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8;
|
||||
}
|
||||
if let Some(size) = tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32) {
|
||||
if let Some(img_pixmap) = Pixmap::from_vec(raw, size) {
|
||||
pixmap.draw_pixmap(
|
||||
rect.x as i32,
|
||||
rect.y as i32,
|
||||
@@ -305,9 +389,38 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Sniff SVG content: either an XML/SVG prolog, or a bare `<svg` root tag
|
||||
/// (both are valid, and image responses commonly omit the XML declaration).
|
||||
fn is_svg(bytes: &[u8]) -> bool {
|
||||
let head = &bytes[..bytes.len().min(256)];
|
||||
let text = String::from_utf8_lossy(head);
|
||||
let trimmed = text.trim_start_matches('\u{feff}').trim_start();
|
||||
trimmed.starts_with("<?xml") || trimmed.starts_with("<svg")
|
||||
}
|
||||
|
||||
/// Rasterize SVG bytes to a `width` x `height` pixmap, scaled to fit (matching
|
||||
/// how a replaced element like `<img>` sizes its intrinsic content).
|
||||
fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option<Pixmap> {
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let opts = usvg::Options::default();
|
||||
let tree = usvg::Tree::from_data(bytes, &opts).ok()?;
|
||||
let size = tree.size();
|
||||
if size.width() <= 0.0 || size.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut svg_pixmap = Pixmap::new(width, height)?;
|
||||
let transform = Transform::from_scale(width as f32 / size.width(), height as f32 / size.height());
|
||||
resvg::render(&tree, transform, &mut svg_pixmap.as_mut());
|
||||
Some(svg_pixmap)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -320,7 +433,7 @@ mod tests {
|
||||
let tree = parse_html(
|
||||
"<html><body><div style=\"background-color: #ff0000; width: 100px; height: 80px\"></div></body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
assert_eq!(pixmap.width(), 200);
|
||||
// The red div is laid out at the origin; sample inside it.
|
||||
let inside = pixmap.pixel(10, 10).expect("pixel");
|
||||
@@ -345,7 +458,7 @@ mod tests {
|
||||
</div>\
|
||||
</body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let p = pixmap.pixel(5, 5).expect("pixel");
|
||||
assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p);
|
||||
}
|
||||
@@ -355,7 +468,7 @@ mod tests {
|
||||
let tree = parse_html(
|
||||
"<html><body><div style=\"color: #00ff00; width: 100px; height: 100px\">Hello</div></body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let mut found_green = false;
|
||||
for y in 0..200 {
|
||||
for x in 0..200 {
|
||||
|
||||
Reference in new issue
Block a user