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:
SGavrl committed 2026-07-03 12:04:03 +00:00
1 parent cd1ee2dcc5
commit 920977776c
12 files changed
+1949 -225

No files matched your search

Generated
+285
View File
@@ -382,6 +382,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -558,6 +564,15 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f" checksum = "147be55d677052dabc6b22252d5dd0fd4c29c8c27aa4f2fbef0f94aa003b406f"
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -625,6 +640,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]] [[package]]
name = "debugid" name = "debugid"
version = "0.8.0" version = "0.8.0"
@@ -830,6 +851,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "euclid"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -861,6 +891,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -879,6 +915,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "fontdb"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
dependencies = [
"log",
"slotmap",
"tinyvec",
"ttf-parser",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -1104,6 +1152,16 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.3" version = "0.3.3"
@@ -1436,6 +1494,22 @@ dependencies = [
"png 0.17.16", "png 0.17.16",
] ]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -1512,6 +1586,18 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "kurbo"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"polycool",
"smallvec",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1540,6 +1626,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@@ -1883,9 +1975,12 @@ dependencies = [
"cssparser", "cssparser",
"image", "image",
"obscura-dom", "obscura-dom",
"resvg",
"taffy", "taffy",
"tiny-skia", "tiny-skia",
"ureq", "ureq",
"url",
"usvg",
] ]
[[package]] [[package]]
@@ -2039,6 +2134,12 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.13" version = "1.1.13"
@@ -2097,6 +2198,15 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "polyval" name = "polyval"
version = "0.6.2" version = "0.6.2"
@@ -2181,6 +2291,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -2399,6 +2515,32 @@ dependencies = [
"webpki-roots 1.0.7", "webpki-roots 1.0.7",
] ]
[[package]]
name = "resvg"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81"
dependencies = [
"gif",
"image-webp",
"log",
"pico-args",
"rgb",
"svgtypes",
"tiny-skia",
"usvg",
"zune-jpeg",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2413,6 +2555,15 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -2487,6 +2638,24 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustybuzz"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
"bitflags 2.13.0",
"bytemuck",
"core_maths",
"log",
"smallvec",
"ttf-parser",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
"unicode-script",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@@ -2662,6 +2831,15 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simplecss"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
dependencies = [
"log",
]
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -2734,6 +2912,9 @@ name = "strict-num"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
dependencies = [
"float-cmp",
]
[[package]] [[package]]
name = "string_cache" name = "string_cache"
@@ -2799,6 +2980,16 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo",
"siphasher",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -3218,6 +3409,9 @@ name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]] [[package]]
name = "tungstenite" name = "tungstenite"
@@ -3262,6 +3456,24 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
[[package]]
name = "unicode-ccc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]] [[package]]
name = "unicode-id-start" name = "unicode-id-start"
version = "1.4.0" version = "1.4.0"
@@ -3274,6 +3486,24 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-vo"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@@ -3324,6 +3554,34 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "usvg"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de"
dependencies = [
"base64",
"data-url",
"flate2",
"fontdb",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
"rustybuzz",
"simplecss",
"siphasher",
"strict-num",
"svgtypes",
"tiny-skia-path",
"ttf-parser",
"unicode-bidi",
"unicode-script",
"unicode-vo",
"xmlwriter",
]
[[package]] [[package]]
name = "utf-8" name = "utf-8"
version = "0.7.6" version = "0.7.6"
@@ -3566,6 +3824,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "which" name = "which"
version = "6.0.3" version = "6.0.3"
@@ -3962,6 +4226,12 @@ dependencies = [
"tap", "tap",
] ]
[[package]]
name = "xmlwriter"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]] [[package]]
name = "yasna" name = "yasna"
version = "0.5.2" version = "0.5.2"
@@ -4107,3 +4377,18 @@ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
] ]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+78 -1
View File
@@ -385,6 +385,78 @@ impl Page {
None => Some(doc_url.clone()), None => Some(doc_url.clone()),
} }
} }
async fn fetch_stylesheets(&mut self) {
let all_links = match &self.js {
Some(js) => {
js.with_dom(|dom| {
let link_ids = dom.query_selector_all("link[rel=\"stylesheet\"]").unwrap_or_default();
let mut links = Vec::new();
for lid in link_ids {
if let Some(node) = dom.get_node(lid) {
if let Some(href) = node.get_attribute("href") {
links.push(href.to_string());
}
}
}
links
}).unwrap_or_default()
}
None => {
tracing::info!("fetch_stylesheets: no js runtime");
return;
}
};
tracing::info!("fetch_stylesheets: found {} stylesheet links", all_links.len());
let document_base = self.resolve_base_url();
let mut fetch_tasks = Vec::new();
for href in all_links {
let full_url = if href.starts_with("http://") || href.starts_with("https://") {
href.clone()
} else if let Some(base) = &document_base {
base.join(&href).map(|u| u.to_string()).unwrap_or_else(|_| href.clone())
} else {
href.clone()
};
tracing::info!("fetch_stylesheets: fetching {}", full_url);
let client = self.http_client.clone();
fetch_tasks.push(tokio::spawn(async move {
if let Ok(url) = url::Url::parse(&full_url) {
if let Ok(Ok(resp)) = tokio::time::timeout(tokio::time::Duration::from_secs(5), client.fetch(&url)).await {
if let Ok(css) = String::from_utf8(resp.body) {
return Some(css);
}
}
}
None
}));
}
let mut all_css = String::new();
for task in fetch_tasks {
if let Ok(Some(css)) = task.await {
tracing::info!("fetch_stylesheets: fetched {} bytes of CSS", css.len());
all_css.push_str(&css);
all_css.push('\n');
}
}
if !all_css.is_empty() {
tracing::info!("fetch_stylesheets: injecting {} bytes of CSS", all_css.len());
if let Some(js) = &mut self.js {
let escaped_css = all_css.replace('\\', "\\\\").replace('`', "\\`").replace('$', "\\$");
let code = format!(
"var style = document.createElement('style'); style.textContent = `{}`; document.head.appendChild(style);",
escaped_css
);
let _ = js.execute_script("<fetch_stylesheets>", &code);
}
}
}
async fn execute_scripts(&mut self) { async fn execute_scripts(&mut self) {
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some()); tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
@@ -1180,6 +1252,7 @@ impl Page {
// listeners never registered, frameworks never bootstrapped, // listeners never registered, frameworks never bootstrapped,
// page.click() handlers were no-ops. Now scripts run regardless // page.click() handlers were no-ops. Now scripts run regardless
// of waitUntil and DCL means "DOM parsed AND scripts executed". // of waitUntil and DCL means "DOM parsed AND scripts executed".
self.fetch_stylesheets().await;
self.execute_scripts().await; self.execute_scripts().await;
self.lifecycle = LifecycleState::DomContentLoaded; self.lifecycle = LifecycleState::DomContentLoaded;
@@ -1286,7 +1359,11 @@ impl Page {
/// viewport is zero-sized. /// viewport is zero-sized.
#[cfg(feature = "render")] #[cfg(feature = "render")]
pub fn screenshot(&self, viewport: (f32, f32)) -> Option<Vec<u8>> { pub fn screenshot(&self, viewport: (f32, f32)) -> Option<Vec<u8>> {
self.with_dom(|dom| obscura_js::screenshot_png(dom, viewport)).flatten() // Needed to resolve the relative image URLs ("logo.svg") that make up
// the overwhelming majority of real markup.
let base_url = self.resolve_base_url();
let base_url = base_url.as_ref().map(|u| u.as_str());
self.with_dom(|dom| obscura_js::screenshot_png(dom, viewport, base_url)).flatten()
} }
/// Absolute URLs the page pulled in via fetch()/XHR (issue #301). Empty /// Absolute URLs the page pulled in via fetch()/XHR (issue #301). Empty
+208 -2
View File
@@ -105,6 +105,15 @@ pub enum PseudoClass {
Enabled, Enabled,
Disabled, Disabled,
Checked, Checked,
/// `:link` / `:any-link`: an `<a>`/`<area>` with an `href`. Extremely
/// common (`a:link { color: ... }` is the standard way sites set their
/// base link color), so treating it as unsupported silently drops the
/// whole rule from the cascade, not just the pseudo-class.
Link,
/// `:visited`. We have no browsing history, so this never matches, the
/// same fallback real browsers use when history-based styling is
/// suppressed for privacy. `:link`/unvisited styling wins by default.
Visited,
} }
impl parser::NonTSPseudoClass for PseudoClass { impl parser::NonTSPseudoClass for PseudoClass {
@@ -138,6 +147,8 @@ impl ToCss for PseudoClass {
PseudoClass::Enabled => dest.write_str(":enabled"), PseudoClass::Enabled => dest.write_str(":enabled"),
PseudoClass::Disabled => dest.write_str(":disabled"), PseudoClass::Disabled => dest.write_str(":disabled"),
PseudoClass::Checked => dest.write_str(":checked"), PseudoClass::Checked => dest.write_str(":checked"),
PseudoClass::Link => dest.write_str(":link"),
PseudoClass::Visited => dest.write_str(":visited"),
} }
} }
} }
@@ -188,6 +199,8 @@ impl<'i> parser::Parser<'i> for ObscuraSelectorParser {
"enabled" => Ok(PseudoClass::Enabled), "enabled" => Ok(PseudoClass::Enabled),
"disabled" => Ok(PseudoClass::Disabled), "disabled" => Ok(PseudoClass::Disabled),
"checked" => Ok(PseudoClass::Checked), "checked" => Ok(PseudoClass::Checked),
"link" | "any-link" => Ok(PseudoClass::Link),
"visited" => Ok(PseudoClass::Visited),
_ => Err(cssparser::ParseError { _ => Err(cssparser::ParseError {
kind: cssparser::ParseErrorKind::Custom( kind: cssparser::ParseErrorKind::Custom(
SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name), SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
@@ -394,10 +407,21 @@ impl<'a> Element for DomElement<'a> {
fn match_non_ts_pseudo_class( fn match_non_ts_pseudo_class(
&self, &self,
_pc: &PseudoClass, pc: &PseudoClass,
_context: &mut MatchingContext<'_, Self::Impl>, _context: &mut MatchingContext<'_, Self::Impl>,
) -> bool { ) -> bool {
false match pc {
PseudoClass::Link => self.is_link(),
PseudoClass::Visited => false,
// Dynamic user-interaction and form-state pseudo-classes have no
// meaning against a static DOM snapshot with no live user input.
PseudoClass::Hover
| PseudoClass::Active
| PseudoClass::Focus
| PseudoClass::Enabled
| PseudoClass::Disabled
| PseudoClass::Checked => false,
}
} }
fn match_pseudo_element( fn match_pseudo_element(
@@ -662,6 +686,188 @@ impl DomTree {
} }
Ok(results) Ok(results)
} }
/// Parse a single selector once for repeated single-element matching, and
/// precompute its specificity, ancestor hashes, and "subject key" (the
/// rightmost id/class/tag used to bucket the rule for fast candidate
/// lookup). Returns `None` if the selector fails to parse.
///
/// This is the primitive a stylesheet cascade builds on: compile every rule
/// once, index by key, then only test the handful of candidate rules against
/// each element instead of scanning the whole tree per rule.
pub fn compile_rule_selector(&self, selector: &str) -> Option<CompiledSelector> {
let list = parse_selector(selector).ok()?;
let sel = list.slice().first()?.clone();
let specificity = sel.specificity();
let key = subject_key(&sel);
let hashes = parser::AncestorHashes::new(&sel, QuirksMode::NoQuirks);
Some(CompiledSelector { sel, specificity, key, hashes })
}
/// Does a single element match a precompiled selector? Allocates a fresh
/// match cache each call; for many matches in a row use [`DomTree::matcher`].
pub fn element_matches(&self, nid: NodeId, compiled: &CompiledSelector) -> bool {
self.matcher().matches(self, nid, compiled)
}
/// A reusable matcher that holds the selector caches and an ancestor bloom
/// filter, so a cascade can fast-reject most (element, rule) pairs without
/// walking the selector's combinators. Reuse one across a whole cascade
/// pass and drive [`Matcher::push_ancestor`] / [`Matcher::pop_ancestor`] as
/// you descend/ascend the tree so the filter tracks the current path.
pub fn matcher(&self) -> Matcher {
Matcher {
caches: selectors::context::SelectorCaches::default(),
ancestors: AncestorFilter::new(),
}
}
}
/// Holds the servo selector match caches plus an incremental ancestor bloom
/// filter so a treewalk cascade can test thousands of (element, rule) pairs
/// cheaply: most rules are fast-rejected by the filter before any combinator
/// walking happens.
pub struct Matcher {
caches: selectors::context::SelectorCaches,
ancestors: AncestorFilter,
}
impl Matcher {
/// Match `nid` (matched as if it were the subject element; the ancestor
/// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`.
pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool {
if !tree.with_node(nid, |n| n.is_element()).unwrap_or(false) {
return false;
}
let mut context = MatchingContext::new(
MatchingMode::Normal,
Some(self.ancestors.filter()),
&mut self.caches,
QuirksMode::NoQuirks,
NeedsSelectorFlags::No,
MatchingForInvalidation::No,
);
let element = DomElement::new(tree, nid);
selectors::matching::matches_selector(&compiled.sel, 0, Some(&compiled.hashes), &element, &mut context)
}
/// Push `nid`'s hashes onto the ancestor filter before descending into its
/// children. Must be paired with a matching [`Matcher::pop_ancestor`].
pub fn push_ancestor(&mut self, tree: &DomTree, nid: NodeId) {
self.ancestors.push(tree, nid);
}
/// Pop the hashes pushed by the most recent unmatched [`Matcher::push_ancestor`].
pub fn pop_ancestor(&mut self) {
self.ancestors.pop();
}
}
/// An incremental ancestor bloom filter (the same structure browsers use to
/// fast-reject selector matches during a treewalk). `push` is called with each
/// element on the way down the tree, `pop` on the way back up, so at any point
/// the filter holds exactly the hashes of the current node's ancestor chain.
struct AncestorFilter {
filter: Box<selectors::bloom::BloomFilter>,
/// Hashes pushed per tree-depth level, so `pop` knows what to remove.
levels: Vec<Vec<u32>>,
}
impl AncestorFilter {
fn new() -> Self {
AncestorFilter { filter: Box::default(), levels: Vec::new() }
}
fn filter(&self) -> &selectors::bloom::BloomFilter {
&self.filter
}
fn push(&mut self, tree: &DomTree, nid: NodeId) {
let mut hashes = Vec::new();
tree.with_node(nid, |node| {
if let Some(elem) = node.as_element() {
push_hash(&mut hashes, &mut self.filter, CssLocalName(elem.local.clone()).precomputed_hash());
if let Some(id) = node.get_attribute("id") {
push_hash(&mut hashes, &mut self.filter, CssString(id.to_string()).precomputed_hash());
}
if let Some(class) = node.get_attribute("class") {
for c in class.split_whitespace() {
push_hash(&mut hashes, &mut self.filter, CssString(c.to_string()).precomputed_hash());
}
}
}
});
self.levels.push(hashes);
}
fn pop(&mut self) {
if let Some(hashes) = self.levels.pop() {
for h in hashes {
self.filter.remove_hash(h);
}
}
}
}
fn push_hash(hashes: &mut Vec<u32>, filter: &mut selectors::bloom::BloomFilter, hash: u32) {
let masked = hash & selectors::bloom::BLOOM_HASH_MASK;
filter.insert_hash(masked);
hashes.push(masked);
}
/// The rightmost simple selector used to index a rule for fast lookup. A rule is
/// only tested against elements that carry its key.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SelectorKey {
Id(String),
Class(String),
Local(String),
Universal,
}
/// A parsed selector plus its cached specificity and subject key.
pub struct CompiledSelector {
sel: parser::Selector<ObscuraSelector>,
specificity: u32,
key: SelectorKey,
hashes: parser::AncestorHashes,
}
impl CompiledSelector {
pub fn key(&self) -> &SelectorKey {
&self.key
}
pub fn specificity(&self) -> u32 {
self.specificity
}
}
/// Pick the index key from a selector's rightmost compound: prefer id, then
/// class, then local name; fall back to Universal for `*`, attribute-only, or
/// pseudo-only subjects.
fn subject_key(sel: &parser::Selector<ObscuraSelector>) -> SelectorKey {
use parser::Component;
let mut local: Option<String> = None;
let mut class: Option<String> = None;
let mut id: Option<String> = None;
for comp in sel.iter_raw_match_order() {
match comp {
Component::Combinator(_) => break,
Component::ID(v) => id = Some(v.0.clone()),
Component::Class(v) => class = Some(v.0.clone()),
Component::LocalName(l) => local = Some(l.lower_name.0.to_string()),
_ => {}
}
}
if let Some(i) = id {
SelectorKey::Id(i)
} else if let Some(c) = class {
SelectorKey::Class(c)
} else if let Some(l) = local {
SelectorKey::Local(l)
} else {
SelectorKey::Universal
}
} }
#[cfg(test)] #[cfg(test)]
+12 -1
View File
@@ -10,7 +10,7 @@ description = "Optional scoped render layer for Obscura: layout (taffy) and, wit
default = [] default = []
# Rasterize the laid-out tree to a pixmap (tiny-skia) with text (ab_glyph) and images. # Rasterize the laid-out tree to a pixmap (tiny-skia) with text (ab_glyph) and images.
# Off by default; the layout core (getBoundingClientRect etc.) works without it. # Off by default; the layout core (getBoundingClientRect etc.) works without it.
paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:image", "dep:ureq", "dep:base64"] paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url"]
[dependencies] [dependencies]
taffy = { workspace = true } taffy = { workspace = true }
@@ -20,6 +20,17 @@ ab_glyph = { workspace = true, optional = true }
image = { version = "0.24", default-features = false, features = ["jpeg", "png", "webp"], optional = true } image = { version = "0.24", default-features = false, features = ["jpeg", "png", "webp"], optional = true }
ureq = { version = "2.9", default-features = false, features = ["tls"], optional = true } ureq = { version = "2.9", default-features = false, features = ["tls"], optional = true }
base64 = { workspace = true, optional = true } base64 = { workspace = true, optional = true }
# SVG rasterization for <img src="*.svg">, inline <svg>, and background-image
# SVGs, all extremely common for logos/icons. Pure Rust, CPU, no system deps,
# same deterministic-rendering guarantee as the rest of this crate.
resvg = { version = "0.47", default-features = false, features = ["text", "raster-images"], optional = true }
usvg = { version = "0.47", default-features = false, optional = true }
url = { workspace = true, optional = true }
cssparser = "0.34" cssparser = "0.34"
[lib] [lib]
path = "src/lib.rs" path = "src/lib.rs"
[[bin]]
name = "paint_file"
path = "src/bin/paint_file.rs"
required-features = ["paint"]
Binary file not shown.
@@ -0,0 +1,20 @@
// Offline paint harness: read an HTML file, paint it at a viewport, write PNG.
// Usage: paint_file <input.html> <output.png> [width] [height] [base_url]
use std::fs;
use obscura_dom::tree_sink::parse_html;
use obscura_render::screenshot_png;
fn main() {
let args: Vec<String> = std::env::args().collect();
let input = args.get(1).map(|s| s.as_str()).unwrap_or("wiki_prepared.html");
let output = args.get(2).map(|s| s.as_str()).unwrap_or("wiki_out.png");
let w: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(1280.0);
let h: f32 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(720.0);
let base_url = args.get(5).map(|s| s.as_str());
let html = fs::read_to_string(input).expect("read input html");
let tree = parse_html(&html);
let png = screenshot_png(&tree, (w, h), base_url).expect("screenshot");
fs::write(output, &png).expect("write png");
eprintln!("wrote {} ({} bytes) at {}x{}", output, png.len(), w, h);
}
+258
View File
@@ -0,0 +1,258 @@
//! Stylesheet cascade: parse `<style>` text into rules once, index each rule by
//! its subject key (id / class / tag), and resolve the matching declarations for
//! an element by testing only the handful of candidate rules that share a key.
//!
//! This replaces the naive "run every selector against the whole tree" approach,
//! which is O(rules x nodes) and dominated render time on large pages (thousands
//! of rules). The indexed cascade is closer to how real browsers match: bucket
//! rules, gather candidates per element, then match and sort by specificity.
use obscura_dom::selector::{CompiledSelector, Matcher, SelectorKey};
use obscura_dom::tree::{DomTree, NodeId};
use std::collections::HashMap;
use crate::LayoutStyle;
struct Rule {
sel: CompiledSelector,
decls: String,
/// Source order, for breaking specificity ties (later wins).
order: usize,
}
/// An indexed set of author rules ready for fast per-element matching.
pub struct Stylesheet {
rules: Vec<Rule>,
by_id: HashMap<String, Vec<usize>>,
by_class: HashMap<String, Vec<usize>>,
by_local: HashMap<String, Vec<usize>>,
universal: Vec<usize>,
}
impl Stylesheet {
/// Parse and index a set of raw CSS sources (the text of each `<style>`
/// block, in document order). Selectors that fail to parse are dropped.
pub fn parse(tree: &DomTree, sources: &[String]) -> Self {
let mut sheet = Stylesheet {
rules: Vec::new(),
by_id: HashMap::new(),
by_class: HashMap::new(),
by_local: HashMap::new(),
universal: Vec::new(),
};
let mut order = 0usize;
for src in sources {
for (selector, decls) in parse_stylesheet(src) {
let Some(sel) = tree.compile_rule_selector(&selector) else { continue };
let idx = sheet.rules.len();
match sel.key() {
SelectorKey::Id(v) => sheet.by_id.entry(v.clone()).or_default().push(idx),
SelectorKey::Class(v) => sheet.by_class.entry(v.clone()).or_default().push(idx),
SelectorKey::Local(v) => sheet.by_local.entry(v.clone()).or_default().push(idx),
SelectorKey::Universal => sheet.universal.push(idx),
}
sheet.rules.push(Rule { sel, decls, order });
order += 1;
}
}
sheet
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
#[doc(hidden)]
pub fn debug_stats(&self) -> (usize, usize, usize, usize, usize) {
(self.rules.len(), self.by_id.len(), self.by_class.len(), self.by_local.len(), self.universal.len())
}
/// Apply every author rule that matches `nid` to `style`, in cascade order
/// (ascending specificity, then source order, so the winner is applied last).
/// `id`, `classes`, and `local` are the element's precomputed keys.
pub fn apply(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
id: Option<&str>,
classes: &[String],
local: &str,
style: &mut LayoutStyle,
) {
// (specificity, order, rule index) for each matching rule.
let mut matched: Vec<(u32, usize, usize)> = Vec::new();
let mut consider = |bucket: Option<&Vec<usize>>, matched: &mut Vec<(u32, usize, usize)>| {
if let Some(idxs) = bucket {
for &i in idxs {
let rule = &self.rules[i];
if matcher.matches(tree, nid, &rule.sel) {
matched.push((rule.sel.specificity(), rule.order, i));
}
}
}
};
consider(self.by_local.get(local), &mut matched);
if let Some(id) = id {
consider(self.by_id.get(id), &mut matched);
}
for c in classes {
consider(self.by_class.get(c), &mut matched);
}
if !self.universal.is_empty() {
consider(Some(&self.universal), &mut matched);
}
if matched.is_empty() {
return;
}
matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
for (_, _, i) in matched {
crate::style::apply_inline(style, &self.rules[i].decls);
}
}
}
/// Split a stylesheet into `(selector, declarations)` rules. Handles nested
/// braces, `/* comments */`, and the at-rules that carry ordinary rules inside
/// (`@media`, `@supports`); other at-rules (`@font-face`, `@keyframes`, ...) are
/// skipped since they do not contribute layout-relevant declarations here.
pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
let mut rules = Vec::new();
let mut current_selector = String::new();
let mut current_decls = String::new();
let mut block_depth = 0;
let mut in_comment = false;
let mut chars = css.chars().peekable();
while let Some(c) = chars.next() {
if in_comment {
if c == '*' && chars.peek() == Some(&'/') {
chars.next();
in_comment = false;
}
continue;
}
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
in_comment = true;
continue;
}
if c == '{' {
if block_depth != 0 {
current_decls.push(c);
}
block_depth += 1;
} else if c == '}' {
block_depth -= 1;
if block_depth == 0 {
let sel = current_selector.trim();
let decls = current_decls.trim();
if let Some(at) = sel.strip_prefix('@') {
flush_at_rule(at, sel, decls, &mut rules);
} else {
for s in sel.split(',') {
let s = s.trim();
if !s.is_empty() {
rules.push((s.to_string(), decls.to_string()));
}
}
}
current_selector.clear();
current_decls.clear();
} else {
current_decls.push(c);
}
} else if block_depth > 0 {
current_decls.push(c);
} else {
current_selector.push(c);
}
}
rules
}
/// Handle the at-rules whose bodies contain ordinary rules. For `@media`, apply
/// the inner rules only when the query holds for a desktop 1280px viewport.
fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, String)>) {
if at.starts_with("media") {
if media_query_applies(sel) {
rules.extend(parse_stylesheet(inner));
}
} else if at.starts_with("supports") {
rules.extend(parse_stylesheet(inner));
}
// Other at-rules (@font-face, @keyframes, @import, ...) carry no
// layout-relevant rules for us, so drop them.
}
/// Coarse `@media` evaluation against an assumed 1280px-wide desktop viewport.
///
/// Real stylesheets format media features inconsistently
/// (`max-width:750px`, `max-width: 750px`, even `max-width : 750px`), so this
/// strips whitespace before scanning: CSS gives no semantic meaning to spaces
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
/// than special-case every formatting variant a site might use.
fn media_query_applies(query: &str) -> bool {
const VIEWPORT_W: f32 = 1280.0;
if query.contains("print") {
return false;
}
let compact: String = query.chars().filter(|c| !c.is_whitespace()).collect();
if let Some(px) = extract_px(&compact, "max-width:") {
if VIEWPORT_W > px {
return false;
}
}
if let Some(px) = extract_px(&compact, "min-width:") {
if VIEWPORT_W < px {
return false;
}
}
true
}
/// Read the number immediately following `prop` in `s`. Callers pass a
/// whitespace-stripped `s`, so the first non-digit character always ends the
/// number. Handles a bare px value (`750px`) and the extremely common
/// responsive-breakpoint idiom `calc(750px - 1px)` (real stylesheets use this
/// constantly to express "one pixel narrower than the next breakpoint"):
/// evaluated as a simple left-to-right sum of every px term, since css media
/// feature calc() expressions in practice are always plain +/- of px values,
/// never nested, multiplied, or mixed-unit.
fn extract_px(s: &str, prop: &str) -> Option<f32> {
let start = s.find(prop)? + prop.len();
let rest = &s[start..];
if let Some(inner) = rest.strip_prefix("calc(") {
let end = inner.find(')')?;
return Some(eval_px_sum(&inner[..end]));
}
let num: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
num.parse::<f32>().ok()
}
/// Sum a sequence of signed px terms like `1120px-1px` (whitespace already
/// stripped by the caller) left to right.
fn eval_px_sum(expr: &str) -> f32 {
let mut total = 0.0;
let mut sign = 1.0;
let mut num = String::new();
let flush = |num: &mut String, sign: f32, total: &mut f32| {
if let Ok(v) = num.parse::<f32>() {
*total += sign * v;
}
num.clear();
};
for c in expr.chars() {
match c {
'+' => { flush(&mut num, sign, &mut total); sign = 1.0; }
'-' => { flush(&mut num, sign, &mut total); sign = -1.0; }
c if c.is_ascii_digit() || c == '.' => num.push(c),
_ => {} // unit suffix (px) or anything else: not part of the number
}
}
flush(&mut num, sign, &mut total);
total
}
+265 -108
View File
@@ -13,53 +13,134 @@ use taffy::prelude::*;
use crate::{Rect, to_taffy_style}; use crate::{Rect, to_taffy_style};
/// Text width for layout. With the `paint` feature this is exact (real glyph
/// metrics from the embedded font, shared with rasterization). Without it
/// (layout-only builds, e.g. for `getBoundingClientRect`), fall back to a
/// standard average-character-width heuristic for proportional fonts so
/// `obscura-render` compiles and gives reasonable geometry with its default
/// (lightest-weight) feature set, matching RENDER.md's documented "layout
/// (default)" mode.
#[cfg(feature = "paint")]
fn text_width(text: &str, size: f32, is_bold: bool) -> f32 {
crate::paint::measure_text(text, size, is_bold)
}
#[cfg(not(feature = "paint"))]
fn text_width(text: &str, size: f32, is_bold: bool) -> f32 {
const AVG_CHAR_WIDTH_EM: f32 = 0.55;
let chars = text.chars().filter(|c| !c.is_control()).count() as f32;
let width = chars * size * AVG_CHAR_WIDTH_EM;
if is_bold { width * 1.08 } else { width }
}
/// Per-element border boxes after layout, in viewport coordinates. /// Per-element border boxes after layout, in viewport coordinates.
pub struct DomLayout { pub struct DomLayout {
pub rects: HashMap<NodeId, Rect>, pub rects: HashMap<NodeId, Rect>,
pub styles: HashMap<NodeId, crate::LayoutStyle>, pub styles: HashMap<NodeId, crate::LayoutStyle>,
/// The clip rect inherited from ancestor `overflow: hidden` boxes, keyed
/// per node. `None` means unclipped. Does not include the node's own
/// overflow (that only clips its children, not itself).
pub clip_rects: HashMap<NodeId, Option<Rect>>,
}
/// Walk the tree top-down accumulating the clip rect imposed by ancestor
/// `overflow: hidden` boxes. Must run after layout, since it needs border
/// boxes. This is what makes `overflow: hidden` (used pervasively for the
/// "visually hidden but accessible" pattern: a 1x1 absolutely-positioned box
/// with clipped overflow) actually hide its contents instead of letting text
/// paint wherever the box's static position happens to land.
fn resolve_clip_rects(
tree: &DomTree,
id: NodeId,
inherited: Option<Rect>,
rects: &HashMap<NodeId, Rect>,
styles: &HashMap<NodeId, crate::LayoutStyle>,
clip_rects: &mut HashMap<NodeId, Option<Rect>>,
) {
clip_rects.insert(id, inherited);
let next = match (styles.get(&id), rects.get(&id)) {
(Some(style), Some(rect)) if style.overflow_hidden => {
Some(match inherited {
Some(clip) => clip.intersect(rect).unwrap_or(Rect::default()),
None => *rect,
})
}
_ => inherited,
};
for cid in tree.children(id) {
resolve_clip_rects(tree, cid, next, rects, styles, clip_rects);
}
}
/// Compute the UA + author style for every element in preorder, maintaining
/// `matcher`'s ancestor filter as we descend so descendant-combinator rules
/// fast-reject correctly. Non-element nodes (text, comments) are skipped but
/// still walked through, since an element may be their descendant.
fn cascade_walk(
tree: &DomTree,
id: NodeId,
sheet: &crate::css::Stylesheet,
matcher: &mut obscura_dom::selector::Matcher,
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
) {
let Some(node) = tree.get_node(id) else { return };
let is_element = node.as_element().map(|elem| {
let mut style = crate::style::ua_style(elem.local.as_ref());
if !sheet.is_empty() {
let node_id = node.get_attribute("id");
let classes: Vec<String> = node
.get_attribute("class")
.map(|c| c.split_whitespace().map(|s| s.to_string()).collect())
.unwrap_or_default();
sheet.apply(tree, matcher, id, node_id, &classes, elem.local.as_ref(), &mut style);
}
styles.insert(id, style);
});
if is_element.is_some() {
matcher.push_ancestor(tree, id);
}
for cid in tree.children(id) {
cascade_walk(tree, cid, sheet, matcher, styles);
}
if is_element.is_some() {
matcher.pop_ancestor();
}
} }
/// Lay out a DOM tree within `viewport` (width, height) in CSS pixels. /// Lay out a DOM tree within `viewport` (width, height) in CSS pixels.
pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout { pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
let mut css_rules = Vec::new(); let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
// Collect the text of every <style> block in document order.
let mut css_sources = Vec::new();
for nid in tree.descendants(tree.document()) { for nid in tree.descendants(tree.document()) {
if let Some(node) = tree.get_node(nid) { if let Some(node) = tree.get_node(nid) {
if let Some(elem) = node.as_element() { if let Some(elem) = node.as_element() {
if elem.local.as_ref() == "style" { if elem.local.as_ref() == "style" {
let css = tree.text_content(nid); css_sources.push(tree.text_content(nid));
css_rules.extend(parse_simple_css(&css));
} }
} }
} }
} }
let t0 = std::time::Instant::now();
let sheet = crate::css::Stylesheet::parse(tree, &css_sources);
let t_parse = t0.elapsed();
let t1 = std::time::Instant::now();
let mut matcher = tree.matcher();
let mut styles: HashMap<NodeId, crate::LayoutStyle> = HashMap::new(); let mut styles: HashMap<NodeId, crate::LayoutStyle> = HashMap::new();
for nid in tree.descendants(tree.document()) { // A real preorder walk (not a flat descendants() scan) so the matcher's
if let Some(node) = tree.get_node(nid) { // ancestor bloom filter tracks the current path: push before recursing
if let Some(elem) = node.as_element() { // into children, pop on the way back out. This is what lets descendant
styles.insert(nid, crate::style::ua_style(elem.local.as_ref())); // combinators (".mw-body .firstHeading") fast-reject via the filter
} // instead of falling back to the always-true "can't reject" case.
} cascade_walk(tree, tree.document(), &sheet, &mut matcher, &mut styles);
if timing {
let (r, i, c, l, u) = sheet.debug_stats();
eprintln!("[timing] parse+index={:?} cascade={:?} rules={} id_keys={} class_keys={} local_keys={} universal={}", t_parse, t1.elapsed(), r, i, c, l, u);
} }
css_rules.push((".subtext".into(), "color: #828282; font-size: 7pt".into()));
css_rules.push((".subline".into(), "color: #828282; font-size: 7pt".into()));
css_rules.push((".hnname".into(), "margin-right: 10px; margin-left: 2px".into()));
css_rules.push((".pagetop".into(), "align-items: center".into()));
css_rules.push((".votearrow".into(), "width: 10px; height: 10px; margin-top: 2px".into()));
css_rules.push((".rank".into(), "color: #828282".into()));
css_rules.push((".comhead".into(), "color: #828282; font-size: 7pt".into()));
css_rules.push(("a".into(), "color: #000000".into()));
for (selector, decls) in &css_rules {
if let Ok(matched) = tree.query_selector_all(selector) {
for nid in matched {
if let Some(style) = styles.get_mut(&nid) {
crate::style::apply_inline(style, decls);
}
}
}
}
for nid in tree.descendants(tree.document()) { for nid in tree.descendants(tree.document()) {
if let Some(node) = tree.get_node(nid) { if let Some(node) = tree.get_node(nid) {
if node.is_element() { if node.is_element() {
@@ -90,21 +171,13 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
crate::style::apply_inline(style, &format!("height: {}", height)); crate::style::apply_inline(style, &format!("height: {}", height));
} }
} }
// For table cells with right alignment, make them grow to push content to the right
if let Some(elem) = node.as_element() {
let local = elem.local.as_ref();
if local == "td" || local == "th" {
let is_title = node.get_attribute("class").unwrap_or_default().contains("title");
if !is_title && style.justify_content == Some(taffy::JustifyContent::FlexEnd) {
style.flex_grow = Some(1.0);
}
}
}
} }
} }
} }
} }
grow_trailing_auto_cells(tree, &mut styles);
let mut taffy_tree: TaffyTree = TaffyTree::new(); let mut taffy_tree: TaffyTree = TaffyTree::new();
let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new(); let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new();
@@ -117,25 +190,27 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
let mut rects = HashMap::new(); let mut rects = HashMap::new();
if let Some(root_id) = root { if let Some(root_id) = root {
let mut queue = vec![(root_id, None, None)]; // Top-down inheritance of the properties CSS inherits by default.
while let Some((id, mut parent_color, mut parent_size)) = queue.pop() { #[derive(Clone, Default)]
struct Inherited {
color: Option<[u8; 4]>,
font_size: Option<f32>,
font_weight: Option<String>,
}
let mut queue = vec![(root_id, Inherited::default())];
while let Some((id, mut inh)) = queue.pop() {
if let Some(style) = styles.get_mut(&id) { if let Some(style) = styles.get_mut(&id) {
if style.color.is_some() { match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color }
parent_color = style.color; match style.font_size { Some(s) => inh.font_size = Some(s), None => style.font_size = inh.font_size }
} else if parent_color.is_some() { match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() }
style.color = parent_color;
}
if style.font_size.is_some() {
parent_size = style.font_size;
} else if parent_size.is_some() {
style.font_size = parent_size;
}
} }
for cid in tree.children(id).into_iter().rev() { for cid in tree.children(id).into_iter().rev() {
queue.push((cid, parent_color, parent_size)); queue.push((cid, inh.clone()));
} }
} }
resolve_grid_areas(tree, root_id, &mut styles);
if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &styles) { if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &styles) {
let _ = taffy_tree.compute_layout( let _ = taffy_tree.compute_layout(
taffy_root, taffy_root,
@@ -147,7 +222,105 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &mut rects); compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &mut rects);
} }
} }
DomLayout { rects, styles }
let mut clip_rects = HashMap::new();
if let Some(root_id) = root {
resolve_clip_rects(tree, root_id, None, &rects, &styles, &mut clip_rects);
}
DomLayout { rects, styles, clip_rects }
}
/// HTML table auto-layout approximation: for each `<tr>`, the last `<td>`/
/// `<th>` child with no explicit width absorbs the row's leftover space. Real
/// browsers run a genuine column-width-negotiation algorithm across every row
/// in a table; giving `flex_grow` to *every* auto-width cell approximates that
/// badly; multiple growing siblings compete and the split becomes sensitive to
/// each cell's own content size, which looks like near-random drift row to
/// row. Growing only the trailing auto cell matches the extremely common
/// layout intent (fixed-size leading cells, one expanding trailing cell) and
/// leaves the others shrink-to-fit, matching a shrink-to-fit table exactly
/// when there is no surplus width to distribute in the first place.
fn grow_trailing_auto_cells(tree: &DomTree, styles: &mut HashMap<NodeId, crate::LayoutStyle>) {
let is_tag = |id: NodeId, tags: &[&str]| -> bool {
match tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) {
Some(local) => tags.contains(&local.as_str()),
None => false,
}
};
for tr in tree.descendants(tree.document()) {
if !is_tag(tr, &["tr"]) {
continue;
}
let last_auto_cell = tree.children(tr).into_iter().rev().find(|&cid| {
is_tag(cid, &["td", "th"])
&& styles.get(&cid).map(|s| s.width == crate::Dimension::Auto && s.flex_grow.is_none()).unwrap_or(false)
});
if let Some(cid) = last_auto_cell {
if let Some(style) = styles.get_mut(&cid) {
style.flex_grow = Some(1.0);
}
}
}
}
/// Walk the tree; for each `display: grid` element that declares
/// `grid-template-areas`, resolve each direct child's `grid-area` name to a
/// taffy line placement. This is how Vector 2022 (and most grid layouts) place
/// their header/sidebar/content/footer regions.
fn resolve_grid_areas(
tree: &DomTree,
root: NodeId,
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
) {
let mut stack = vec![root];
while let Some(id) = stack.pop() {
for cid in tree.children(id) {
stack.push(cid);
}
let areas = match styles.get(&id) {
Some(s) if s.display == crate::Display::Grid => match &s.grid_areas {
Some(a) if !a.is_empty() => a.clone(),
_ => continue,
},
_ => continue,
};
// name -> (row_start, row_end, col_start, col_end) in 0-based track indices.
let mut spans: HashMap<String, (usize, usize, usize, usize)> = HashMap::new();
for (r, row) in areas.iter().enumerate() {
for (c, name) in row.iter().enumerate() {
if name == "." {
continue;
}
spans
.entry(name.clone())
.and_modify(|s| {
s.0 = s.0.min(r);
s.1 = s.1.max(r);
s.2 = s.2.min(c);
s.3 = s.3.max(c);
})
.or_insert((r, r, c, c));
}
}
for cid in tree.children(id) {
let Some(cstyle) = styles.get_mut(&cid) else { continue };
let Some(name) = cstyle.grid_area_name.clone() else { continue };
if let Some(&(r0, r1, c0, c1)) = spans.get(&name) {
use taffy::style_helpers::line;
cstyle.grid_row = Some(taffy::Line {
start: line((r0 + 1) as i16),
end: line((r1 + 2) as i16),
});
cstyle.grid_column = Some(taffy::Line {
start: line((c0 + 1) as i16),
end: line((c1 + 2) as i16),
});
}
}
}
} }
fn compute_absolute_rects( fn compute_absolute_rects(
@@ -182,36 +355,18 @@ fn compute_absolute_rects(
} }
} }
fn parse_simple_css(css: &str) -> Vec<(String, String)> { /// Does `id` have any direct child that is inline-level (a non-whitespace
let mut rules = Vec::new(); /// text node, or an element whose resolved display is `Inline`)? Used to
let mut current_selector = String::new(); /// decide whether a block container needs the flex-row-wrap approximation of
let mut current_decls = String::new(); /// an inline formatting context.
let mut in_block = false; fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>) -> bool {
tree.children(id).into_iter().any(|cid| {
for c in css.chars() { let Some(node) = tree.get_node(cid) else { return false };
if c == '{' && !in_block { match &node.data {
in_block = true; obscura_dom::tree::NodeData::Text { contents } => !contents.trim().is_empty(),
} else if c == '}' && in_block { _ => styles.get(&cid).map(|s| s.display == crate::Display::Inline).unwrap_or(false),
in_block = false;
let sel = current_selector.trim();
let decls = current_decls.trim();
for s in sel.split(',') {
let s = s.trim();
if !s.is_empty() {
rules.push((s.to_string(), decls.to_string()));
}
}
current_selector.clear();
current_decls.clear();
} else {
if in_block {
current_decls.push(c);
} else {
current_selector.push(c);
}
} }
} })
rules
} }
fn build( fn build(
@@ -237,10 +392,8 @@ fn build(
in_space = false; in_space = false;
} }
} }
if display_text == " " {
display_text = " ".to_string(); // Keep single space
}
let mut fsize = 16.0; let mut fsize = 16.0;
let mut is_bold = false; let mut is_bold = false;
if let Some(parent_id) = node.parent { if let Some(parent_id) = node.parent {
@@ -250,9 +403,18 @@ fn build(
} }
} }
let width = crate::paint::measure_text(&display_text, fsize, is_bold); let width = text_width(&display_text, fsize, is_bold);
let height = fsize * 1.2; // A text node that is pure whitespace is almost always HTML source
// formatting (a newline and indentation between sibling tags), not
// meaningful content: real browsers collapse it away entirely. We
// still give it its natural (small) width, since a bare space
// between two inline elements ("<a>Foo</a> <a>Bar</a>") does need to
// keep them visually separated, but zero height, so it never adds a
// spurious blank row when it lands between block-level siblings
// (e.g. formatting whitespace around a run of now-collapsed,
// display:none list items).
let height = if contents.trim().is_empty() { 0.0 } else { fsize * 1.2 };
let taffy_style = taffy::Style { let taffy_style = taffy::Style {
size: taffy::Size { size: taffy::Size {
width: taffy::Dimension::Length(width), width: taffy::Dimension::Length(width),
@@ -271,26 +433,21 @@ fn build(
return None; return None;
} }
let mut taffy_style = to_taffy_style(style); let mut taffy_style = to_taffy_style(style);
// Emulate HTML table layout by giving the last td in a tr all remaining space // Taffy has no real inline formatting context: its native Block layout
if _name.local.as_ref() == "td" || _name.local.as_ref() == "th" { // treats every direct child as its own full-width block box, stacked
if let Some(parent_id) = node.parent { // vertically. That is correct when a block's children are other block
if let Some(parent) = tree.get_node(parent_id) { // elements (a div full of divs), but wrong the moment a block holds
if parent.as_element().map_or(false, |e| e.local.as_ref() == "tr") { // inline-level content (running text with <a>/<span>/<b> mixed in, i.e.
let mut last_td = None; // any ordinary paragraph or list item), where real CSS instead lays
for cid in tree.children(parent_id) { // consecutive inline boxes out left-to-right, wrapping at the container's
if let Some(child) = tree.get_node(cid) { // width. Approximate that inline formatting context by promoting such
if child.as_element().map_or(false, |e| e.local.as_ref() == "td" || e.local.as_ref() == "th") { // blocks to a wrapping flex row, so text and inline elements actually
last_td = Some(cid); // flow together on shared lines instead of each getting its own row.
} if style.display == crate::Display::Block && has_inline_content(tree, id, styles) {
} taffy_style.display = taffy::style::Display::Flex;
} taffy_style.flex_direction = taffy::FlexDirection::Row;
if last_td == Some(id) { taffy_style.flex_wrap = taffy::FlexWrap::Wrap;
taffy_style.flex_grow = 1.0;
}
}
}
}
} }
let child_ids: Vec<taffy::NodeId> = tree let child_ids: Vec<taffy::NodeId> = tree
+141 -3
View File
@@ -13,10 +13,13 @@
use taffy::prelude::*; use taffy::prelude::*;
mod style; pub mod css;
pub use css::Stylesheet;
pub mod style;
pub use style::compute_style; pub use style::compute_style;
mod dom; pub mod dom;
pub use dom::{layout_dom, DomLayout}; pub use dom::{layout_dom, DomLayout};
#[cfg(feature = "paint")] #[cfg(feature = "paint")]
@@ -33,6 +36,23 @@ pub struct Rect {
pub height: f32, pub height: f32,
} }
impl Rect {
/// The overlap of two rects, or `None` if they do not intersect (or the
/// overlap is degenerate). Used to accumulate an ancestor clip chain for
/// `overflow: hidden`.
pub fn intersect(&self, other: &Rect) -> Option<Rect> {
let x0 = self.x.max(other.x);
let y0 = self.y.max(other.y);
let x1 = (self.x + self.width).min(other.x + other.width);
let y1 = (self.y + self.height).min(other.y + other.height);
if x1 > x0 && y1 > y0 {
Some(Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
} else {
None
}
}
}
/// Per-edge box values (margin / padding / border) in CSS pixels. /// Per-edge box values (margin / padding / border) in CSS pixels.
#[derive(Debug, Clone, Copy, Default, PartialEq)] #[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Edges { pub struct Edges {
@@ -49,6 +69,7 @@ pub enum Display {
#[default] #[default]
Block, Block,
Flex, Flex,
Grid,
Inline, Inline,
#[allow(dead_code)] #[allow(dead_code)]
None, None,
@@ -68,11 +89,30 @@ pub struct LayoutStyle {
pub display: Display, pub display: Display,
pub width: Dimension, pub width: Dimension,
pub height: Dimension, pub height: Dimension,
pub min_width: Dimension,
pub min_height: Dimension,
pub max_width: Dimension,
pub max_height: Dimension,
pub margin: Edges, pub margin: Edges,
pub padding: Edges, pub padding: Edges,
pub border: Edges, pub border: Edges,
/// RGBA for the paint step. Parsed always (cheap), used only with `paint`. /// RGBA for the paint step. Parsed always (cheap), used only with `paint`.
pub background_color: Option<[u8; 4]>, pub background_color: Option<[u8; 4]>,
/// The first `url(...)` reference from `background`/`background-image`
/// (gradients and repeat keywords in the same shorthand are ignored: we
/// paint the referenced image, not the gradient layer).
pub background_image: Option<String>,
/// `background-size`, in px, when given as explicit length(s) (a bare
/// `10px` applies to both axes, matching how small square icons are
/// almost always sized). `None` means "fill the whole box" (our fallback
/// when the value is a keyword we don't evaluate, `cover`/`contain`, or
/// absent with an already icon-sized box like the HN vote arrow).
pub background_size: Option<(f32, f32)>,
/// `background-position` as a 0.0-1.0 fraction per axis (0,0 = default
/// top-left; 1,0.5 = "right center"). Only meaningful alongside
/// `background_size`: without a known image size there is no leftover
/// box space to position within.
pub background_position: (f32, f32),
/// Foreground (text) color for the paint step. /// Foreground (text) color for the paint step.
pub color: Option<[u8; 4]>, pub color: Option<[u8; 4]>,
pub border_color: Option<[u8; 4]>, pub border_color: Option<[u8; 4]>,
@@ -80,8 +120,33 @@ pub struct LayoutStyle {
pub font_weight: Option<String>, pub font_weight: Option<String>,
pub align_items: Option<taffy::AlignItems>, pub align_items: Option<taffy::AlignItems>,
pub flex_direction: Option<taffy::FlexDirection>, pub flex_direction: Option<taffy::FlexDirection>,
pub flex_wrap: Option<taffy::FlexWrap>,
pub justify_content: Option<taffy::JustifyContent>, pub justify_content: Option<taffy::JustifyContent>,
pub flex_grow: Option<f32>, pub flex_grow: Option<f32>,
pub flex_shrink: Option<f32>,
// CSS Grid. Tracks are stored as taffy sizing functions; `grid_areas` is the
// parsed `grid-template-areas` matrix (one Vec per row, `.` for a null cell),
// resolved to line placements on children in a later pass.
pub grid_template_columns: Vec<taffy::TrackSizingFunction>,
pub grid_template_rows: Vec<taffy::TrackSizingFunction>,
pub grid_areas: Option<Vec<Vec<String>>>,
pub grid_area_name: Option<String>,
pub grid_column: Option<taffy::Line<taffy::GridPlacement>>,
pub grid_row: Option<taffy::Line<taffy::GridPlacement>>,
pub column_gap: Option<f32>,
pub row_gap: Option<f32>,
// Positioning. `position: absolute|fixed` takes the box out of normal flow.
pub position: Option<taffy::Position>,
pub inset: [Option<f32>; 4], // top, right, bottom, left
/// `overflow`/-x/-y other than `visible`: clips this element's descendants
/// to its border box during paint. This is what makes the ubiquitous
/// "visually-hidden but accessible" pattern (a 1x1 absolutely-positioned,
/// clipped box used for skip-links and screen-reader-only labels) actually
/// invisible instead of painting its text wherever it lands.
pub overflow_hidden: bool,
} }
/// A node in the input layout tree. `text` is carried for the paint phase; it /// A node in the input layout tree. `text` is carried for the paint phase; it
@@ -154,15 +219,34 @@ fn read_node(tree: &TaffyTree, id: NodeId) -> NodeRect {
pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
let mut s = Style::DEFAULT; let mut s = Style::DEFAULT;
// A block box that wants non-default cross-axis alignment (from
// text-align: center/right, the only way our engine currently sets
// align_items on a block-level element) needs a flex column to have any
// effect at all: taffy's native block algorithm (used for plain
// Display::Block) has no align-items concept whatsoever, it only ever
// places block children at the start edge. Promote such boxes to a flex
// column, matching the same column-flex approximation already used for
// elements like <td> and <center>.
let promote_for_alignment = style.display == Display::Block
&& matches!(style.align_items, Some(taffy::AlignItems::Center) | Some(taffy::AlignItems::FlexEnd));
s.display = match style.display { s.display = match style.display {
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
Display::Block => taffy::style::Display::Block, Display::Block => taffy::style::Display::Block,
Display::Flex => taffy::style::Display::Flex, Display::Flex => taffy::style::Display::Flex,
Display::Grid => taffy::style::Display::Grid,
Display::Inline => taffy::style::Display::Flex, Display::Inline => taffy::style::Display::Flex,
Display::None => taffy::style::Display::None, Display::None => taffy::style::Display::None,
}; };
if promote_for_alignment {
s.flex_direction = taffy::FlexDirection::Column;
}
if let Some(fd) = style.flex_direction { if let Some(fd) = style.flex_direction {
s.flex_direction = fd; s.flex_direction = fd;
s.flex_wrap = taffy::FlexWrap::NoWrap; }
if let Some(fw) = style.flex_wrap {
s.flex_wrap = fw;
} else if style.display == Display::Inline { } else if style.display == Display::Inline {
s.flex_direction = taffy::FlexDirection::Row; s.flex_direction = taffy::FlexDirection::Row;
s.flex_wrap = taffy::FlexWrap::Wrap; s.flex_wrap = taffy::FlexWrap::Wrap;
@@ -173,6 +257,14 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
width: dimension(style.width), width: dimension(style.width),
height: dimension(style.height), height: dimension(style.height),
}; };
s.min_size = taffy::Size {
width: dimension(style.min_width),
height: dimension(style.min_height),
};
s.max_size = taffy::Size {
width: dimension(style.max_width),
height: dimension(style.max_height),
};
if let Some(ai) = style.align_items { if let Some(ai) = style.align_items {
s.align_items = Some(ai); s.align_items = Some(ai);
} }
@@ -182,12 +274,58 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
if let Some(fg) = style.flex_grow { if let Some(fg) = style.flex_grow {
s.flex_grow = fg; s.flex_grow = fg;
} }
if let Some(fs) = style.flex_shrink {
s.flex_shrink = fs;
}
// Grid container tracks and gaps.
if style.display == Display::Grid {
if !style.grid_template_columns.is_empty() {
s.grid_template_columns = style.grid_template_columns.clone();
}
if !style.grid_template_rows.is_empty() {
s.grid_template_rows = style.grid_template_rows.clone();
}
}
let cg = style.column_gap.unwrap_or(0.0);
let rg = style.row_gap.unwrap_or(0.0);
s.gap = taffy::Size {
width: taffy::style::LengthPercentage::Length(cg),
height: taffy::style::LengthPercentage::Length(rg),
};
// Grid item placement (resolved from grid-area names or explicit lines).
if let Some(gc) = style.grid_column {
s.grid_column = gc;
}
if let Some(gr) = style.grid_row {
s.grid_row = gr;
}
// Positioning. Absolute/fixed take the box out of flow.
if let Some(pos) = style.position {
s.position = pos;
s.inset = taffy::Rect {
top: inset_lpa(style.inset[0]),
right: inset_lpa(style.inset[1]),
bottom: inset_lpa(style.inset[2]),
left: inset_lpa(style.inset[3]),
};
}
s.margin = rect_auto(style.margin); s.margin = rect_auto(style.margin);
s.padding = rect_lp(style.padding); s.padding = rect_lp(style.padding);
s.border = rect_lp(style.border); s.border = rect_lp(style.border);
s s
} }
fn inset_lpa(v: Option<f32>) -> taffy::style::LengthPercentageAuto {
match v {
Some(px) => taffy::style::LengthPercentageAuto::Length(px),
None => taffy::style::LengthPercentageAuto::Auto,
}
}
fn dimension(v: Dimension) -> taffy::style::Dimension { fn dimension(v: Dimension) -> taffy::style::Dimension {
match v { match v {
Dimension::Px(px) => taffy::style::Dimension::Length(px), Dimension::Px(px) => taffy::style::Dimension::Length(px),
+200 -87
View File
@@ -14,13 +14,21 @@ static FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
use crate::layout_dom; use crate::layout_dom;
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or /// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or
/// None if the viewport is zero-sized. /// None if the viewport is zero-sized. `base_url`, when given, resolves the
pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> { /// 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 (w, h) = (viewport.0 as u32, viewport.1 as u32);
let mut pixmap = Pixmap::new(w, h)?; let mut pixmap = Pixmap::new(w, h)?;
pixmap.fill(Color::WHITE); pixmap.fill(Color::WHITE);
let laid = layout_dom(tree, viewport); 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). // Tree order so later elements paint over earlier ones (normal flow).
for nid in tree.descendants(tree.document()) { for nid in tree.descendants(tree.document()) {
let node = match tree.get_node(nid) { 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, Some(r) => r,
None => continue, 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) { let style = match laid.styles.get(&nid) {
Some(s) => s, Some(s) => s,
None => continue, 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 { if let Some(bg) = style.background_color {
let mut path = PathBuilder::new(); let mut path = PathBuilder::new();
path.push_rect(box_rect); 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); 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 { 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 bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]);
let mut paint = Paint::default(); let mut paint = Paint::default();
paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3])); paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3]));
paint.anti_alias = false; paint.anti_alias = false;
let mut path = PathBuilder::new(); let mut path = PathBuilder::new();
if style.border.top > 0.0 { let mut push_clipped = |x: f32, y: f32, w: f32, h: f32| {
if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width, style.border.top) { let edge = crate::Rect { x, y, width: w, height: h };
path.push_rect(r); 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 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) { push_clipped(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height);
path.push_rect(r);
}
} }
if style.border.bottom > 0.0 { 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) { push_clipped(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom);
path.push_rect(r);
}
} }
if style.border.left > 0.0 { if style.border.left > 0.0 {
if let Some(r) = Rect::from_xywh(rect.x, rect.y, style.border.left, rect.height) { push_clipped(rect.x, rect.y, style.border.left, rect.height);
path.push_rect(r);
}
} }
if let Some(path) = path.finish() { if let Some(path) = path.finish() {
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
} }
} }
if name.local.as_ref() == "img" { if name.local.as_ref() == "img" {
let src = node.get_attribute("src").unwrap_or(""); if let Some(src) = node.get_attribute("src") {
if src.contains("y18") { let painted = paint_image(src, base_url, &rect, &mut pixmap, &mut image_cache);
let mut paint = tiny_skia::Paint { // Fall back to alt text only when the image itself did not paint.
shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 255, 255, 255)), if !painted {
..Default::default() if let Some(alt) = node.get_attribute("alt") {
}; if !alt.is_empty() {
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) { draw_text(&mut pixmap, alt, rect.x, rect.y, [0, 0, 0, 255], 12.0, false, clip);
pixmap.fill_rect(r, &paint, Transform::identity(), None); }
}
} }
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 /// Render `tree` at `viewport` to PNG bytes (RGBA 8-bit). Returns None if the
/// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`. /// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`.
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32)) -> Option<Vec<u8>> { pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Vec<u8>> {
paint_dom(tree, viewport)?.encode_png().ok() paint_dom(tree, viewport, base_url)?.encode_png().ok()
} }
fn paint_text_node( fn paint_text_node(
@@ -179,13 +209,9 @@ fn paint_text_node(
Some(r) => r, Some(r) => r,
None => return None, None => return None,
}; };
if text.contains("zachtronics") || text.contains("Exapunks") { let clip = laid.clip_rects.get(&nid).copied().flatten();
println!("DEBUG text: {:?} rect: {:?}", text, rect); draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold, clip);
}
// Paint text at rect.x, rect.y
draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold);
Some(()) Some(())
} }
@@ -202,7 +228,14 @@ pub fn measure_text(text: &str, size: f32, is_bold: bool) -> f32 {
width 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 font = FontRef::try_from_slice(FONT_BYTES).unwrap();
let scale = PxScale::from(size); let scale = PxScale::from(size);
let scaled_font = font.as_scaled(scale); 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 width = pixmap.width() as i32;
let height = pixmap.height() 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 pixels = pixmap.pixels_mut();
let (r, g, b, a_full) = (color[0], color[1], color[2], color[3]); 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| { outlined.draw(|gx, gy, c| {
let px = (bounds.min.x + gx as f32) as i32; let px = (bounds.min.x + gx as f32) as i32;
let py = (bounds.min.y + gy 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 { if px >= 0 && px < width && py >= 0 && py < height {
let alpha = (a_full as f32 * c) as u8; let alpha = (a_full as f32 * c) as u8;
if alpha > 0 { 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 { if rect.width <= 0.0 || rect.height <= 0.0 {
return; return false;
} }
let bytes = if src.starts_with("data:image/") { 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; use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(&src[comma_idx + 1..]).ok() base64::engine::general_purpose::STANDARD.decode(&src[comma_idx + 1..]).ok()
} else { None } } 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 { } 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 { let Some(bytes) = bytes else { return false };
if let Ok(img) = image::load_from_memory(&bytes) {
let img = img.to_rgba8(); if is_svg(&bytes) {
let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle); if let Some(svg_pixmap) = render_svg(&bytes, rect.width as u32, rect.height as u32) {
pixmap.draw_pixmap(
let mut raw = resized.into_raw(); rect.x as i32,
for pixel in raw.chunks_exact_mut(4) { rect.y as i32,
let a = pixel[3] as u32; svg_pixmap.as_ref(),
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8; &tiny_skia::PixmapPaint::default(),
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8; Transform::identity(),
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8; None,
} );
if let Some(img_pixmap) = Pixmap::from_vec(raw, tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32).unwrap()) { 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( pixmap.draw_pixmap(
rect.x as i32, rect.x as i32,
rect.y as i32, rect.y as i32,
@@ -305,9 +389,38 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
Transform::identity(), Transform::identity(),
None, 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)] #[cfg(test)]
@@ -320,7 +433,7 @@ mod tests {
let tree = parse_html( let tree = parse_html(
"<html><body><div style=\"background-color: #ff0000; width: 100px; height: 80px\"></div></body></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); assert_eq!(pixmap.width(), 200);
// The red div is laid out at the origin; sample inside it. // The red div is laid out at the origin; sample inside it.
let inside = pixmap.pixel(10, 10).expect("pixel"); let inside = pixmap.pixel(10, 10).expect("pixel");
@@ -345,7 +458,7 @@ mod tests {
</div>\ </div>\
</body></html>", </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"); let p = pixmap.pixel(5, 5).expect("pixel");
assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p); assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p);
} }
@@ -355,7 +468,7 @@ mod tests {
let tree = parse_html( let tree = parse_html(
"<html><body><div style=\"color: #00ff00; width: 100px; height: 100px\">Hello</div></body></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; let mut found_green = false;
for y in 0..200 { for y in 0..200 {
for x in 0..200 { for x in 0..200 {
+398 -23
View File
@@ -38,9 +38,33 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
style.flex_direction = Some(taffy::FlexDirection::Column); style.flex_direction = Some(taffy::FlexDirection::Column);
style.align_items = Some(taffy::AlignItems::Center); style.align_items = Some(taffy::AlignItems::Center);
} else if tag == "head" || tag == "script" || tag == "style" || tag == "title" || tag == "meta" || tag == "link" { } else if tag == "head" || tag == "script" || tag == "style" || tag == "title" || tag == "meta" || tag == "link" {
style.display = Display::None; style.display = crate::Display::None;
} else if tag == "body" { } else if tag == "body" {
style.margin = Edges { top: 8.0, right: 8.0, bottom: 8.0, left: 8.0 }; style.margin = Edges { top: 8.0, right: 8.0, bottom: 8.0, left: 8.0 };
} else if tag == "h1" {
style.font_size = Some(32.0);
style.font_weight = Some("bold".to_string());
style.margin = Edges { top: 21.0, bottom: 21.0, left: 0.0, right: 0.0 };
} else if tag == "h2" {
style.font_size = Some(24.0);
style.font_weight = Some("bold".to_string());
style.margin = Edges { top: 19.0, bottom: 19.0, left: 0.0, right: 0.0 };
} else if tag == "h3" {
style.font_size = Some(18.7);
style.font_weight = Some("bold".to_string());
style.margin = Edges { top: 18.0, bottom: 18.0, left: 0.0, right: 0.0 };
} else if tag == "h4" || tag == "h5" || tag == "h6" {
style.font_size = Some(16.0);
style.font_weight = Some("bold".to_string());
style.margin = Edges { top: 21.0, bottom: 21.0, left: 0.0, right: 0.0 };
} else if tag == "p" || tag == "ul" || tag == "ol" {
style.margin = Edges { top: 16.0, bottom: 16.0, left: 0.0, right: 0.0 };
} else if tag == "li" {
style.margin = Edges { top: 0.0, bottom: 0.0, left: 40.0, right: 0.0 };
} else if tag == "b" || tag == "strong" || tag == "th" {
style.font_weight = Some("bold".to_string());
} else if tag == "a" {
style.color = Some([0, 0, 238, 255]); // blue
} else if tag == "table" || tag == "tbody" { } else if tag == "table" || tag == "tbody" {
style.display = Display::Flex; style.display = Display::Flex;
style.flex_direction = Some(taffy::FlexDirection::Column); style.flex_direction = Some(taffy::FlexDirection::Column);
@@ -76,14 +100,22 @@ pub fn apply_inline(style: &mut LayoutStyle, css: &str) {
fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
match name { match name {
"display" => match value { "display" => match value {
"block" => style.display = Display::Block, "none" => style.display = crate::Display::None,
"flex" => style.display = Display::Flex, "flex" => style.display = crate::Display::Flex,
"inline" => style.display = Display::Inline, "inline-flex" => style.display = crate::Display::Flex,
"none" => style.display = Display::None, "inline" => style.display = crate::Display::Inline,
"inline-block" => style.display = crate::Display::Inline,
"grid" => style.display = crate::Display::Grid,
"inline-grid" => style.display = crate::Display::Grid,
"block" => style.display = crate::Display::Block,
_ => {} _ => {}
}, },
"width" => style.width = dimension_value(value), "width" => style.width = dimension_value(value),
"height" => style.height = dimension_value(value), "height" => style.height = dimension_value(value),
"min-width" => style.min_width = dimension_value(value),
"min-height" => style.min_height = dimension_value(value),
"max-width" => style.max_width = dimension_value(value),
"max-height" => style.max_height = dimension_value(value),
"margin" => { if let Some(e) = edges(value) { style.margin = e; } } "margin" => { if let Some(e) = edges(value) { style.margin = e; } }
"margin-top" => set_edge(&mut style.margin, Side::Top, px(value)), "margin-top" => set_edge(&mut style.margin, Side::Top, px(value)),
"margin-right" => set_edge(&mut style.margin, Side::Right, px(value)), "margin-right" => set_edge(&mut style.margin, Side::Right, px(value)),
@@ -108,36 +140,304 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
"border-right-width" | "border-right" => set_edge(&mut style.border, Side::Right, px(value)), "border-right-width" | "border-right" => set_edge(&mut style.border, Side::Right, px(value)),
"border-bottom-width" | "border-bottom" => set_edge(&mut style.border, Side::Bottom, px(value)), "border-bottom-width" | "border-bottom" => set_edge(&mut style.border, Side::Bottom, px(value)),
"border-left-width" | "border-left" => set_edge(&mut style.border, Side::Left, px(value)), "border-left-width" | "border-left" => set_edge(&mut style.border, Side::Left, px(value)),
"background-color" | "background" => style.background_color = parse_color(value), "background-color" => style.background_color = parse_color(value),
"background" => {
style.background_color = parse_color(value);
style.background_image = parse_url(value);
}
"background-image" => style.background_image = parse_url(value),
"background-size" => style.background_size = parse_background_size(value),
"background-position" => style.background_position = parse_background_position(value),
"color" => style.color = parse_color(value), "color" => style.color = parse_color(value),
"border-color" => style.border_color = parse_color(value), "border-color" => style.border_color = parse_color(value),
"font-size" => style.font_size = px(value), "font-size" => style.font_size = px(value),
"font-weight" => style.font_weight = Some(value.to_string()), "font-weight" => style.font_weight = Some(value.to_string()),
"text-align" => { // Our engine has no real inline formatting context, so text-align is
if value == "right" { // approximated the same way as align-items: it positions a block's
style.justify_content = Some(taffy::JustifyContent::FlexEnd); // (or column-flex's) children along the cross axis. See
style.align_items = Some(taffy::AlignItems::FlexEnd); // `to_taffy_style`'s block-to-flex-column promotion, which is what
} else if value == "center" { // makes this actually take effect on plain block elements.
style.justify_content = Some(taffy::JustifyContent::Center); "text-align" => match value {
style.align_items = Some(taffy::AlignItems::Center); "right" => style.align_items = Some(taffy::AlignItems::FlexEnd),
} "center" => style.align_items = Some(taffy::AlignItems::Center),
"left" | "start" | "justify" => style.align_items = Some(taffy::AlignItems::FlexStart),
_ => {}
}, },
"align-items" => { "align-items" => {
if value == "center" { match value {
style.align_items = Some(taffy::AlignItems::Center); "center" => style.align_items = Some(taffy::AlignItems::Center),
} else if value == "flex-start" || value == "start" { "flex-start" | "start" => style.align_items = Some(taffy::AlignItems::FlexStart),
style.align_items = Some(taffy::AlignItems::FlexStart); "flex-end" | "end" => style.align_items = Some(taffy::AlignItems::FlexEnd),
} else if value == "flex-end" || value == "end" { "stretch" => style.align_items = Some(taffy::AlignItems::Stretch),
style.align_items = Some(taffy::AlignItems::FlexEnd); "baseline" => style.align_items = Some(taffy::AlignItems::Baseline),
_ => {}
} }
}, },
"justify-content" => {
match value {
"center" => style.justify_content = Some(taffy::JustifyContent::Center),
"flex-start" | "start" | "left" => style.justify_content = Some(taffy::JustifyContent::FlexStart),
"flex-end" | "end" | "right" => style.justify_content = Some(taffy::JustifyContent::FlexEnd),
"space-between" => style.justify_content = Some(taffy::JustifyContent::SpaceBetween),
"space-around" => style.justify_content = Some(taffy::JustifyContent::SpaceAround),
"space-evenly" => style.justify_content = Some(taffy::JustifyContent::SpaceEvenly),
_ => {}
}
},
"flex-direction" => {
match value {
"row" => style.flex_direction = Some(taffy::FlexDirection::Row),
"row-reverse" => style.flex_direction = Some(taffy::FlexDirection::RowReverse),
"column" => style.flex_direction = Some(taffy::FlexDirection::Column),
"column-reverse" => style.flex_direction = Some(taffy::FlexDirection::ColumnReverse),
_ => {}
}
},
"flex-wrap" => {
match value {
"wrap" => style.flex_wrap = Some(taffy::FlexWrap::Wrap),
"nowrap" => style.flex_wrap = Some(taffy::FlexWrap::NoWrap),
"wrap-reverse" => style.flex_wrap = Some(taffy::FlexWrap::WrapReverse),
_ => {}
}
},
"flex-grow" => { if let Some(v) = token(value).and_then(|t| t.parse::<f32>().ok()) { style.flex_grow = Some(v); } }
"flex-shrink" => { if let Some(v) = token(value).and_then(|t| t.parse::<f32>().ok()) { style.flex_shrink = Some(v); } }
"position" => {
match value {
"absolute" | "fixed" => style.position = Some(taffy::Position::Absolute),
"relative" | "sticky" | "static" => style.position = Some(taffy::Position::Relative),
_ => {}
}
},
"top" => style.inset[0] = px(value),
"right" => style.inset[1] = px(value),
"bottom" => style.inset[2] = px(value),
"left" => style.inset[3] = px(value),
"overflow" | "overflow-x" | "overflow-y" => {
style.overflow_hidden = value != "visible";
}
"gap" | "grid-gap" => {
let dims: Vec<f32> = value.split_whitespace().filter_map(px_value).collect();
if let Some(&r) = dims.first() { style.row_gap = Some(r); style.column_gap = Some(*dims.get(1).unwrap_or(&r)); }
}
"row-gap" | "grid-row-gap" => style.row_gap = px(value),
"column-gap" | "grid-column-gap" => style.column_gap = px(value),
"grid-template-columns" => style.grid_template_columns = parse_track_list(value),
"grid-template-rows" => style.grid_template_rows = parse_track_list(value),
"grid-template-areas" => style.grid_areas = Some(parse_grid_areas(value)),
"grid-template" => parse_grid_template(style, value),
"grid-area" => {
// Named area (single ident) or line form `r/c/r/c`. We only resolve
// the named-area case here; line forms are handled by grid-row/column.
let v = value.trim();
if !v.contains('/') && !v.is_empty() {
style.grid_area_name = Some(v.to_string());
}
}
"grid-column" => style.grid_column = parse_grid_line(value),
"grid-row" => style.grid_row = parse_grid_line(value),
_ => {} _ => {}
} }
} }
/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex and a /// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
/// small set of named colors. Returns None for anything else (transparent). /// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the
fn parse_color(value: &str) -> Option<[u8; 4]> { /// parentheses in `minmax(...)` / `fit-content(...)`.
pub(crate) fn parse_track_list(value: &str) -> Vec<taffy::TrackSizingFunction> {
tokenize_tracks(value).iter().map(|t| track(t)).collect()
}
/// Split a track list on whitespace while keeping `func(a, b)` groups intact.
fn tokenize_tracks(value: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut depth = 0i32;
for c in value.chars() {
match c {
'(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); }
c if c.is_whitespace() && depth == 0 => {
if !cur.is_empty() { out.push(std::mem::take(&mut cur)); }
}
c => cur.push(c),
}
}
if !cur.is_empty() { out.push(cur); }
out
}
fn track(tok: &str) -> taffy::TrackSizingFunction {
use taffy::MinMax;
let t = tok.trim();
let lower = t.to_ascii_lowercase();
if let Some(inner) = lower.strip_prefix("minmax(").and_then(|s| s.strip_suffix(')')) {
if let Some((a, b)) = inner.split_once(',') {
return taffy::TrackSizingFunction::Single(MinMax {
min: min_track(a.trim()),
max: max_track(b.trim()),
});
}
}
if let Some(inner) = lower.strip_prefix("fit-content(").and_then(|s| s.strip_suffix(')')) {
let lp = px_value(inner.trim())
.map(taffy::style::LengthPercentage::Length)
.unwrap_or(taffy::style::LengthPercentage::Length(0.0));
return taffy::TrackSizingFunction::Single(MinMax {
min: taffy::MinTrackSizingFunction::Auto,
max: taffy::MaxTrackSizingFunction::FitContent(lp),
});
}
taffy::TrackSizingFunction::Single(MinMax { min: min_track(t), max: max_track(t) })
}
fn min_track(tok: &str) -> taffy::MinTrackSizingFunction {
use taffy::MinTrackSizingFunction as M;
match tok.to_ascii_lowercase().as_str() {
"min-content" => M::MinContent,
"max-content" => M::MaxContent,
"auto" => M::Auto,
other => {
if other.ends_with("fr") {
// Flexible tracks have an automatic minimum.
M::Auto
} else if let Some(px) = dim_lp(other) {
M::Fixed(px)
} else {
M::Auto
}
}
}
}
fn max_track(tok: &str) -> taffy::MaxTrackSizingFunction {
use taffy::MaxTrackSizingFunction as M;
let lower = tok.to_ascii_lowercase();
match lower.as_str() {
"min-content" => M::MinContent,
"max-content" => M::MaxContent,
"auto" => M::Auto,
other => {
if let Some(fr) = other.strip_suffix("fr").and_then(|n| n.trim().parse::<f32>().ok()) {
M::Fraction(fr)
} else if let Some(px) = dim_lp(other) {
M::Fixed(px)
} else {
M::Auto
}
}
}
}
/// A length or percentage as a taffy `LengthPercentage`.
fn dim_lp(tok: &str) -> Option<taffy::style::LengthPercentage> {
let t = tok.trim();
if let Some(p) = t.strip_suffix('%') {
return p.parse::<f32>().ok().map(|v| taffy::style::LengthPercentage::Percent(v / 100.0));
}
px_value(t).map(taffy::style::LengthPercentage::Length)
}
/// Parse `grid-template-areas: 'a a' 'b c'` into a matrix of cell names.
fn parse_grid_areas(value: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();
let mut in_str = false;
let mut cur = String::new();
for c in value.chars() {
match c {
'\'' | '"' => {
if in_str {
rows.push(cur.split_whitespace().map(|s| s.to_string()).collect::<Vec<_>>());
cur.clear();
in_str = false;
} else {
in_str = true;
}
}
_ if in_str => cur.push(c),
_ => {}
}
}
rows
}
/// Parse the `grid-template: <rows> / <cols>` shorthand. The rows side may embed
/// area strings; we support the common `tracks / tracks` form and, when area
/// strings are present, extract them too.
fn parse_grid_template(style: &mut LayoutStyle, value: &str) {
let (rows_part, cols_part) = match value.split_once('/') {
Some((r, c)) => (r.trim(), Some(c.trim())),
None => (value.trim(), None),
};
if rows_part.contains('\'') || rows_part.contains('"') {
// Rows side carries area strings interleaved with row track sizes.
style.grid_areas = Some(parse_grid_areas(rows_part));
} else if !rows_part.is_empty() {
style.grid_template_rows = parse_track_list(rows_part);
}
if let Some(cols) = cols_part {
style.grid_template_columns = parse_track_list(cols);
}
}
/// Parse `grid-column`/`grid-row` values: `2`, `1 / 3`, `span 2`.
fn parse_grid_line(value: &str) -> Option<taffy::Line<taffy::GridPlacement>> {
let place = |tok: &str| -> taffy::GridPlacement {
let tok = tok.trim();
if let Some(n) = tok.strip_prefix("span").map(|s| s.trim()) {
if let Ok(s) = n.parse::<u16>() { return taffy::style_helpers::span(s); }
}
if let Ok(i) = tok.parse::<i16>() { return taffy::style_helpers::line(i); }
taffy::GridPlacement::Auto
};
let (a, b) = match value.split_once('/') {
Some((a, b)) => (a, Some(b)),
None => (value, None),
};
let start = place(a);
let end = b.map(place).unwrap_or(taffy::GridPlacement::Auto);
Some(taffy::Line { start, end })
}
/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex,
/// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named
/// colors. Returns None for anything else (transparent).
pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
let raw = value.trim();
// CSS custom property with a fallback: var(--name, <fallback>). We cannot
// resolve the variable, but the fallback after the comma is a real color.
if let Some(rest) = raw.strip_prefix("var(") {
let inner = rest.strip_suffix(')').unwrap_or(rest);
if let Some((_, fallback)) = inner.split_once(',') {
return parse_color(fallback.trim());
}
return None;
}
// rgb()/rgba() functional notation.
let lower_full = raw.to_ascii_lowercase();
if let Some(rest) = lower_full.strip_prefix("rgb(").or_else(|| lower_full.strip_prefix("rgba(")) {
let inner = rest.strip_suffix(')').unwrap_or(rest);
let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect();
if parts.len() >= 3 {
let c = |s: &str| -> Option<u8> {
let s = s.trim();
if let Some(pct) = s.strip_suffix('%') {
pct.parse::<f32>().ok().map(|v| (v * 2.55).round().clamp(0.0, 255.0) as u8)
} else {
s.parse::<f32>().ok().map(|v| v.round().clamp(0.0, 255.0) as u8)
}
};
let r = c(parts[0])?;
let g = c(parts[1])?;
let b = c(parts[2])?;
let a = parts.get(3)
.and_then(|s| s.trim().parse::<f32>().ok())
.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8)
.unwrap_or(255);
return Some([r, g, b, a]);
}
return None;
}
let v = value.split_whitespace().next()?.to_ascii_lowercase(); let v = value.split_whitespace().next()?.to_ascii_lowercase();
if let Some(h) = v.strip_prefix('#') { if let Some(h) = v.strip_prefix('#') {
let (r, g, b, a) = match h.len() { let (r, g, b, a) = match h.len() {
@@ -173,10 +473,24 @@ fn parse_color(value: &str) -> Option<[u8; 4]> {
"white" => Some([255, 255, 255, 255]), "white" => Some([255, 255, 255, 255]),
"black" => Some([0, 0, 0, 255]), "black" => Some([0, 0, 0, 255]),
"gray" | "grey" => Some([128, 128, 128, 255]), "gray" | "grey" => Some([128, 128, 128, 255]),
"silver" => Some([192, 192, 192, 255]),
"lightgray" | "lightgrey" => Some([211, 211, 211, 255]),
"darkgray" | "darkgrey" => Some([169, 169, 169, 255]),
"whitesmoke" => Some([245, 245, 245, 255]),
"gainsboro" => Some([220, 220, 220, 255]),
"red" => Some([255, 0, 0, 255]), "red" => Some([255, 0, 0, 255]),
"green" => Some([0, 128, 0, 255]), "green" => Some([0, 128, 0, 255]),
"lime" => Some([0, 255, 0, 255]),
"blue" => Some([0, 0, 255, 255]), "blue" => Some([0, 0, 255, 255]),
"navy" => Some([0, 0, 128, 255]),
"yellow" => Some([255, 255, 0, 255]),
"orange" => Some([255, 165, 0, 255]), "orange" => Some([255, 165, 0, 255]),
"purple" => Some([128, 0, 128, 255]),
"maroon" => Some([128, 0, 0, 255]),
"teal" => Some([0, 128, 128, 255]),
"aqua" | "cyan" => Some([0, 255, 255, 255]),
"fuchsia" | "magenta" => Some([255, 0, 255, 255]),
"olive" => Some([128, 128, 0, 255]),
"transparent" => Some([0, 0, 0, 0]), "transparent" => Some([0, 0, 0, 0]),
_ => None, _ => None,
} }
@@ -247,6 +561,67 @@ fn token(value: &str) -> Option<&str> {
value.split_whitespace().next() value.split_whitespace().next()
} }
/// Extract the first `url(...)` reference from a `background`/`background-image`
/// value, unquoted. Ignores any other layers in the same shorthand (gradients,
/// `no-repeat`, etc.): we paint the referenced image, not the gradient.
fn parse_url(value: &str) -> Option<String> {
let start = value.find("url(")? + 4;
let end = value[start..].find(')')?;
let inner = value[start..start + end].trim();
let unquoted = inner.trim_matches(|c| c == '"' || c == '\'');
if unquoted.is_empty() {
None
} else {
Some(unquoted.to_string())
}
}
/// `background-size: 10px` / `0.857em` / `10px 20px` -> explicit px pair.
/// Keyword values (`cover`, `contain`, `auto`) are left unhandled (`None`, the
/// "stretch to fill the box" fallback) since evaluating them needs the
/// image's own intrinsic aspect ratio, which is not known until it is
/// fetched, well after style resolution.
fn parse_background_size(value: &str) -> Option<(f32, f32)> {
let tokens: Vec<&str> = value.split_whitespace().collect();
match tokens.as_slice() {
[one] => px_value(one).map(|v| (v, v)),
[w, h] => Some((px_value(w)?, px_value(h)?)),
_ => None,
}
}
/// `background-position` keywords/lengths -> a 0.0-1.0 fraction per axis (the
/// fraction of the box's leftover space, after the image's own size, to
/// offset by: 0 = start edge, 1 = end edge, 0.5 = centered).
///
/// `left`/`right` always set the x axis and `top`/`bottom` always set y,
/// regardless of order (CSS allows `center right` and `right center`
/// interchangeably for keyword-only values). Any axis left unset by an
/// explicit keyword defaults to centered, which correctly handles a bare
/// `center` (both axes), a single directional keyword (`right` -> the other
/// axis centers), and explicit percentages (first fills x, second fills y,
/// per CSS shorthand order).
fn parse_background_position(value: &str) -> (f32, f32) {
let mut x = None;
let mut y = None;
for tok in value.split_whitespace() {
match tok {
"left" => x = Some(0.0),
"right" => x = Some(1.0),
"top" => y = Some(0.0),
"bottom" => y = Some(1.0),
t if t.ends_with('%') => {
if let Ok(pct) = t.trim_end_matches('%').parse::<f32>() {
let v = pct / 100.0;
if x.is_none() { x = Some(v); } else { y = Some(v); }
}
}
_ => {} // "center" (or an unhandled bare length): leaves this axis to default below
}
}
(x.unwrap_or(0.5), y.unwrap_or(0.5))
}
/// CSS box shorthand: 1 value applies to all sides; 2 values are (v, h); /// CSS box shorthand: 1 value applies to all sides; 2 values are (v, h);
/// 3 values are (top, h, bottom); 4 values are (top, right, bottom, left). /// 3 values are (top, h, bottom); 4 values are (top, right, bottom, left).
fn edges_from(dims: Vec<f32>) -> Option<Edges> { fn edges_from(dims: Vec<f32>) -> Option<Edges> {
@@ -0,0 +1,84 @@
//! Regression test: a Hacker-News-shaped nested `<table>` layout must come out
//! right using only the general engine (UA defaults + real CSS cascade), with
//! no per-site hardcoded selectors. This guards against reintroducing the
//! site-specific hacks that used to live in obscura-render for this exact markup.
use obscura_dom::tree::NodeData;
use obscura_dom::tree_sink::parse_html;
use obscura_render::layout_dom;
const HN_HTML: &str = r##"
<table border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
<tr>
<td bgcolor="#ff6600">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
<tr>
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18"></a></td>
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b> <a href="newest">new</a> | <a href="front">past</a></span></td>
<td style="text-align:right;padding-right:4px;"><span class="pagetop"><a href="login?goto=news">login</a></span></td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
<tr class="athing submission" id="48761229">
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
<td valign="top" class="votelinks"><center><a id="up_48761229" href="vote?id=48761229"><div class="votearrow" title="upvote"></div></a></center></td>
<td class="title"><span class="titleline"><a href="https://www.zachtronics.com/exapunks/">Exapunks (2018)</a></span></td>
</tr>
</table>
</td>
</tr>
</table>
"##;
fn find_by_text<'a>(
tree: &'a obscura_dom::tree::DomTree,
layout: &'a obscura_render::DomLayout,
needle: &str,
) -> Option<(obscura_dom::tree::NodeId, &'a obscura_render::Rect)> {
for (id, rect) in &layout.rects {
if let Some(node) = tree.get_node(*id) {
if let NodeData::Text { contents } = &node.data {
if contents.trim() == needle {
return Some((*id, rect));
}
}
}
}
None
}
#[test]
fn hn_shaped_table_lays_out_without_site_hardcoding() {
let tree = parse_html(HN_HTML);
let layout = layout_dom(&tree, (1000.0, 1000.0));
// Every element got a rect; the tree isn't just being dropped.
assert!(layout.rects.len() > 10, "expected many laid-out elements, got {}", layout.rects.len());
// "Hacker News" (a bold link in the top bar) sits above the first
// headline row ("Exapunks (2018)"): normal top-to-bottom flow, not a
// hardcoded absolute position.
let (_, brand_rect) = find_by_text(&tree, &layout, "Hacker News").expect("brand text laid out");
let (_, headline_rect) = find_by_text(&tree, &layout, "Exapunks (2018)").expect("headline text laid out");
assert!(
headline_rect.y > brand_rect.y,
"headline should be below the header bar: brand.y={} headline.y={}",
brand_rect.y,
headline_rect.y
);
// Within the header row, "login" (right-aligned cell) sits to the right
// of "Hacker News" (left cell) — plain flex/table layout, not a magic
// per-class x-offset.
let (_, login_rect) = find_by_text(&tree, &layout, "login").expect("login text laid out");
assert!(
login_rect.x > brand_rect.x,
"login cell should be right of the brand: brand.x={} login.x={}",
brand_rect.x,
login_rect.x
);
}