Fix render engine: CSS grid, cascade correctness, SVG, inline flow
The render layer had no CSS Grid support, so Vector 2022's page layout
(three nested grids) collapsed to a vertical stack with the article
landing tens of thousands of pixels down the page. Wikipedia rendered
as a near-blank page and Hacker News only worked through per-site
hardcoded CSS overrides.
Removed the HN-specific hardcoding and fixed the general engine instead:
- CSS Grid layout (grid-template-areas/columns/rows via taffy's grid mode)
- Indexed, specificity-correct cascade in a new css.rs, replacing the
old test-every-selector-against-every-node scan. Cascade time on
Wikipedia went from 9.5s to under 200ms.
- Ancestor bloom-filter fast-rejection for selector matching in
obscura-dom, needed to make the indexed cascade actually fast on
descendant combinators.
- :link/:visited pseudo-classes were previously unparseable, so any
rule using them (a:link{color:...}, extremely common) silently
dropped the whole rule from the cascade.
- overflow:hidden clipping, fixing the pervasive visually-hidden-but-
accessible pattern used for skip links and screen-reader-only labels.
- @media parser robustness: it did a literal substring search for
max-width: and silently dropped the constraint on max-width : 750px
(space before colon) or max-width:calc(750px - 1px), treating
mobile-only or narrow-viewport rules as always active. The calc()
case was hiding Wikipedia's TOC sidebar via a rule meant only for
narrow screens.
- Table auto-layout approximation: the trailing auto-width td in a row
absorbs leftover space, so a width:100% table's unsized last column
reaches the right edge instead of shrinking to content.
- Inline formatting context approximation: a block holding inline-level
children (a paragraph or list item with links) is promoted to a
wrapping flex row so text and links read as continuous prose instead
of one fragment per line.
- Whitespace-only text nodes no longer get a full line-height box. They
used to stack up as blank rows, turning Wikipedia's collapsed TOC
subsections and the page header into hundreds of pixels of dead space.
- SVG rendering via resvg/usvg, plus resolving relative image URLs
against the page's base URL.
- background-image support including background-size/background-position
for correctly sized and positioned icons.
- Per-screenshot image fetch cache.
- Fixed the layout-only (no paint feature) build, which didn't compile
due to an unconditional call into paint-gated code.
This commit is contained in:
1 parent
cd1ee2dcc5
commit
920977776c
12 files changed
+1949
-225
No files matched your search
Generated
+285
@@ -382,6 +382,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -558,6 +564,15 @@ version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -625,6 +640,12 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "debugid"
|
||||
version = "0.8.0"
|
||||
@@ -830,6 +851,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -861,6 +891,12 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -879,6 +915,18 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1104,6 +1152,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@@ -1436,6 +1494,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -1512,6 +1586,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1540,6 +1626,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -1883,9 +1975,12 @@ dependencies = [
|
||||
"cssparser",
|
||||
"image",
|
||||
"obscura-dom",
|
||||
"resvg",
|
||||
"taffy",
|
||||
"tiny-skia",
|
||||
"ureq",
|
||||
"url",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2039,6 +2134,12 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2097,6 +2198,15 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polycool"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -2181,6 +2291,12 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -2399,6 +2515,32 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2413,6 +2555,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2487,6 +2638,24 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -2662,6 +2831,15 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simplecss"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -2734,6 +2912,9 @@ name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
@@ -2799,6 +2980,16 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -3218,6 +3409,9 @@ name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
@@ -3262,6 +3456,24 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unicode-id-start"
|
||||
version = "1.4.0"
|
||||
@@ -3274,6 +3486,24 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -3324,6 +3554,34 @@ dependencies = [
|
||||
"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]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
@@ -3566,6 +3824,12 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "6.0.3"
|
||||
@@ -3962,6 +4226,12 @@ dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlwriter"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
@@ -4107,3 +4377,18 @@ dependencies = [
|
||||
"cc",
|
||||
"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",
|
||||
]
|
||||
@@ -385,6 +385,78 @@ impl Page {
|
||||
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) {
|
||||
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
|
||||
@@ -1180,6 +1252,7 @@ impl Page {
|
||||
// listeners never registered, frameworks never bootstrapped,
|
||||
// page.click() handlers were no-ops. Now scripts run regardless
|
||||
// of waitUntil and DCL means "DOM parsed AND scripts executed".
|
||||
self.fetch_stylesheets().await;
|
||||
self.execute_scripts().await;
|
||||
|
||||
self.lifecycle = LifecycleState::DomContentLoaded;
|
||||
@@ -1286,7 +1359,11 @@ impl Page {
|
||||
/// viewport is zero-sized.
|
||||
#[cfg(feature = "render")]
|
||||
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
|
||||
|
||||
@@ -105,6 +105,15 @@ pub enum PseudoClass {
|
||||
Enabled,
|
||||
Disabled,
|
||||
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 {
|
||||
@@ -138,6 +147,8 @@ impl ToCss for PseudoClass {
|
||||
PseudoClass::Enabled => dest.write_str(":enabled"),
|
||||
PseudoClass::Disabled => dest.write_str(":disabled"),
|
||||
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),
|
||||
"disabled" => Ok(PseudoClass::Disabled),
|
||||
"checked" => Ok(PseudoClass::Checked),
|
||||
"link" | "any-link" => Ok(PseudoClass::Link),
|
||||
"visited" => Ok(PseudoClass::Visited),
|
||||
_ => Err(cssparser::ParseError {
|
||||
kind: cssparser::ParseErrorKind::Custom(
|
||||
SelectorParseErrorKind::UnsupportedPseudoClassOrElement(name),
|
||||
@@ -394,10 +407,21 @@ impl<'a> Element for DomElement<'a> {
|
||||
|
||||
fn match_non_ts_pseudo_class(
|
||||
&self,
|
||||
_pc: &PseudoClass,
|
||||
pc: &PseudoClass,
|
||||
_context: &mut MatchingContext<'_, Self::Impl>,
|
||||
) -> 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(
|
||||
@@ -662,6 +686,188 @@ impl DomTree {
|
||||
}
|
||||
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)]
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "Optional scoped render layer for Obscura: layout (taffy) and, wit
|
||||
default = []
|
||||
# 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.
|
||||
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]
|
||||
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 }
|
||||
ureq = { version = "2.9", default-features = false, features = ["tls"], 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"
|
||||
[lib]
|
||||
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);
|
||||
}
|
||||
@@ -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
@@ -13,53 +13,134 @@ use taffy::prelude::*;
|
||||
|
||||
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.
|
||||
pub struct DomLayout {
|
||||
pub rects: HashMap<NodeId, Rect>,
|
||||
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.
|
||||
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()) {
|
||||
if let Some(node) = tree.get_node(nid) {
|
||||
if let Some(elem) = node.as_element() {
|
||||
if elem.local.as_ref() == "style" {
|
||||
let css = tree.text_content(nid);
|
||||
css_rules.extend(parse_simple_css(&css));
|
||||
css_sources.push(tree.text_content(nid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
if let Some(node) = tree.get_node(nid) {
|
||||
if let Some(elem) = node.as_element() {
|
||||
styles.insert(nid, crate::style::ua_style(elem.local.as_ref()));
|
||||
}
|
||||
}
|
||||
// A real preorder walk (not a flat descendants() scan) so the matcher's
|
||||
// ancestor bloom filter tracks the current path: push before recursing
|
||||
// into children, pop on the way back out. This is what lets descendant
|
||||
// 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()) {
|
||||
if let Some(node) = tree.get_node(nid) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
// 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 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();
|
||||
if let Some(root_id) = root {
|
||||
let mut queue = vec![(root_id, None, None)];
|
||||
while let Some((id, mut parent_color, mut parent_size)) = queue.pop() {
|
||||
// Top-down inheritance of the properties CSS inherits by default.
|
||||
#[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 style.color.is_some() {
|
||||
parent_color = style.color;
|
||||
} else if parent_color.is_some() {
|
||||
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;
|
||||
}
|
||||
match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color }
|
||||
match style.font_size { Some(s) => inh.font_size = Some(s), None => style.font_size = inh.font_size }
|
||||
match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() }
|
||||
}
|
||||
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) {
|
||||
let _ = taffy_tree.compute_layout(
|
||||
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);
|
||||
}
|
||||
}
|
||||
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(
|
||||
@@ -182,36 +355,18 @@ fn compute_absolute_rects(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_simple_css(css: &str) -> Vec<(String, String)> {
|
||||
let mut rules = Vec::new();
|
||||
let mut current_selector = String::new();
|
||||
let mut current_decls = String::new();
|
||||
let mut in_block = false;
|
||||
|
||||
for c in css.chars() {
|
||||
if c == '{' && !in_block {
|
||||
in_block = true;
|
||||
} else if c == '}' && in_block {
|
||||
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);
|
||||
}
|
||||
/// Does `id` have any direct child that is inline-level (a non-whitespace
|
||||
/// text node, or an element whose resolved display is `Inline`)? Used to
|
||||
/// decide whether a block container needs the flex-row-wrap approximation of
|
||||
/// an inline formatting context.
|
||||
fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>) -> bool {
|
||||
tree.children(id).into_iter().any(|cid| {
|
||||
let Some(node) = tree.get_node(cid) else { return false };
|
||||
match &node.data {
|
||||
obscura_dom::tree::NodeData::Text { contents } => !contents.trim().is_empty(),
|
||||
_ => styles.get(&cid).map(|s| s.display == crate::Display::Inline).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
rules
|
||||
})
|
||||
}
|
||||
|
||||
fn build(
|
||||
@@ -237,10 +392,8 @@ fn build(
|
||||
in_space = false;
|
||||
}
|
||||
}
|
||||
if display_text == " " {
|
||||
display_text = " ".to_string(); // Keep single space
|
||||
}
|
||||
|
||||
|
||||
|
||||
let mut fsize = 16.0;
|
||||
let mut is_bold = false;
|
||||
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 height = fsize * 1.2;
|
||||
|
||||
let width = text_width(&display_text, fsize, is_bold);
|
||||
// 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 {
|
||||
size: taffy::Size {
|
||||
width: taffy::Dimension::Length(width),
|
||||
@@ -271,26 +433,21 @@ fn build(
|
||||
return None;
|
||||
}
|
||||
let mut taffy_style = to_taffy_style(style);
|
||||
|
||||
// Emulate HTML table layout by giving the last td in a tr all remaining space
|
||||
if _name.local.as_ref() == "td" || _name.local.as_ref() == "th" {
|
||||
if let Some(parent_id) = node.parent {
|
||||
if let Some(parent) = tree.get_node(parent_id) {
|
||||
if parent.as_element().map_or(false, |e| e.local.as_ref() == "tr") {
|
||||
let mut last_td = None;
|
||||
for cid in tree.children(parent_id) {
|
||||
if let Some(child) = tree.get_node(cid) {
|
||||
if child.as_element().map_or(false, |e| e.local.as_ref() == "td" || e.local.as_ref() == "th") {
|
||||
last_td = Some(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_td == Some(id) {
|
||||
taffy_style.flex_grow = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Taffy has no real inline formatting context: its native Block layout
|
||||
// treats every direct child as its own full-width block box, stacked
|
||||
// vertically. That is correct when a block's children are other block
|
||||
// elements (a div full of divs), but wrong the moment a block holds
|
||||
// inline-level content (running text with <a>/<span>/<b> mixed in, i.e.
|
||||
// any ordinary paragraph or list item), where real CSS instead lays
|
||||
// consecutive inline boxes out left-to-right, wrapping at the container's
|
||||
// width. Approximate that inline formatting context by promoting such
|
||||
// blocks to a wrapping flex row, so text and inline elements actually
|
||||
// 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;
|
||||
taffy_style.flex_wrap = taffy::FlexWrap::Wrap;
|
||||
}
|
||||
|
||||
let child_ids: Vec<taffy::NodeId> = tree
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
|
||||
use taffy::prelude::*;
|
||||
|
||||
mod style;
|
||||
pub mod css;
|
||||
pub use css::Stylesheet;
|
||||
|
||||
pub mod style;
|
||||
pub use style::compute_style;
|
||||
|
||||
mod dom;
|
||||
pub mod dom;
|
||||
pub use dom::{layout_dom, DomLayout};
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
@@ -33,6 +36,23 @@ pub struct Rect {
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct Edges {
|
||||
@@ -49,6 +69,7 @@ pub enum Display {
|
||||
#[default]
|
||||
Block,
|
||||
Flex,
|
||||
Grid,
|
||||
Inline,
|
||||
#[allow(dead_code)]
|
||||
None,
|
||||
@@ -68,11 +89,30 @@ pub struct LayoutStyle {
|
||||
pub display: Display,
|
||||
pub width: Dimension,
|
||||
pub height: Dimension,
|
||||
pub min_width: Dimension,
|
||||
pub min_height: Dimension,
|
||||
pub max_width: Dimension,
|
||||
pub max_height: Dimension,
|
||||
pub margin: Edges,
|
||||
pub padding: Edges,
|
||||
pub border: Edges,
|
||||
/// RGBA for the paint step. Parsed always (cheap), used only with `paint`.
|
||||
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.
|
||||
pub color: Option<[u8; 4]>,
|
||||
pub border_color: Option<[u8; 4]>,
|
||||
@@ -80,8 +120,33 @@ pub struct LayoutStyle {
|
||||
pub font_weight: Option<String>,
|
||||
pub align_items: Option<taffy::AlignItems>,
|
||||
pub flex_direction: Option<taffy::FlexDirection>,
|
||||
pub flex_wrap: Option<taffy::FlexWrap>,
|
||||
pub justify_content: Option<taffy::JustifyContent>,
|
||||
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
|
||||
@@ -154,15 +219,34 @@ fn read_node(tree: &TaffyTree, id: NodeId) -> NodeRect {
|
||||
|
||||
pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
||||
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 {
|
||||
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
|
||||
Display::Block => taffy::style::Display::Block,
|
||||
Display::Flex => taffy::style::Display::Flex,
|
||||
Display::Grid => taffy::style::Display::Grid,
|
||||
Display::Inline => taffy::style::Display::Flex,
|
||||
Display::None => taffy::style::Display::None,
|
||||
};
|
||||
if promote_for_alignment {
|
||||
s.flex_direction = taffy::FlexDirection::Column;
|
||||
}
|
||||
if let Some(fd) = style.flex_direction {
|
||||
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 {
|
||||
s.flex_direction = taffy::FlexDirection::Row;
|
||||
s.flex_wrap = taffy::FlexWrap::Wrap;
|
||||
@@ -173,6 +257,14 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
||||
width: dimension(style.width),
|
||||
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 {
|
||||
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 {
|
||||
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.padding = rect_lp(style.padding);
|
||||
s.border = rect_lp(style.border);
|
||||
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 {
|
||||
match v {
|
||||
Dimension::Px(px) => taffy::style::Dimension::Length(px),
|
||||
|
||||
@@ -14,13 +14,21 @@ static FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
|
||||
use crate::layout_dom;
|
||||
|
||||
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or
|
||||
/// None if the viewport is zero-sized.
|
||||
pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
/// None if the viewport is zero-sized. `base_url`, when given, resolves the
|
||||
/// relative image URLs (`<img src="logo.svg">`) that make up the overwhelming
|
||||
/// majority of real-world markup; without it only absolute and `data:` URLs
|
||||
/// can be fetched.
|
||||
pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Pixmap> {
|
||||
let (w, h) = (viewport.0 as u32, viewport.1 as u32);
|
||||
let mut pixmap = Pixmap::new(w, h)?;
|
||||
pixmap.fill(Color::WHITE);
|
||||
|
||||
let laid = layout_dom(tree, viewport);
|
||||
// The same URL (an icon sprite, a repeated background image) commonly
|
||||
// backs many elements on one page; fetch each distinct URL at most once
|
||||
// per screenshot. `None` caches a failed fetch too, so a broken image
|
||||
// reference does not retry on every element that references it.
|
||||
let mut image_cache: std::collections::HashMap<String, Option<Vec<u8>>> = std::collections::HashMap::new();
|
||||
// Tree order so later elements paint over earlier ones (normal flow).
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
let node = match tree.get_node(nid) {
|
||||
@@ -41,16 +49,29 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
let box_rect = match Rect::from_xywh(rect.x, rect.y, rect.width, rect.height) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
|
||||
let style = match laid.styles.get(&nid) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
|
||||
// Ancestor `overflow: hidden` clip, if any. Skip painting entirely
|
||||
// once the box has no visible overlap with it (this is what makes the
|
||||
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
|
||||
// actually invisible instead of painting text wherever it lands).
|
||||
let clip = laid.clip_rects.get(&nid).copied().flatten();
|
||||
let visible_rect = match clip {
|
||||
Some(c) => match rect.intersect(&c) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
},
|
||||
None => *rect,
|
||||
};
|
||||
let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(bg) = style.background_color {
|
||||
let mut path = PathBuilder::new();
|
||||
path.push_rect(box_rect);
|
||||
@@ -61,69 +82,78 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let Some(bg_url) = &style.background_image {
|
||||
// With an explicit background-size, paint the image at that size
|
||||
// positioned within the box per background-position, instead of
|
||||
// stretching it to fill the whole element: a small icon
|
||||
// (background-size: 0.857em) on a wide text link must stay small
|
||||
// and corner-positioned, not balloon to the link's full width.
|
||||
let img_rect = match style.background_size {
|
||||
Some((iw, ih)) => {
|
||||
let (px, py) = style.background_position;
|
||||
crate::Rect {
|
||||
x: rect.x + (rect.width - iw).max(0.0) * px,
|
||||
y: rect.y + (rect.height - ih).max(0.0) * py,
|
||||
width: iw,
|
||||
height: ih,
|
||||
}
|
||||
}
|
||||
None => *rect,
|
||||
};
|
||||
let img_rect = match clip {
|
||||
Some(c) => img_rect.intersect(&c),
|
||||
None => Some(img_rect),
|
||||
};
|
||||
if let Some(img_rect) = img_rect {
|
||||
paint_image(bg_url, base_url, &img_rect, &mut pixmap, &mut image_cache);
|
||||
}
|
||||
}
|
||||
|
||||
if style.border.top > 0.0 || style.border.right > 0.0 || style.border.bottom > 0.0 || style.border.left > 0.0 {
|
||||
let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]);
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3]));
|
||||
paint.anti_alias = false;
|
||||
|
||||
|
||||
let mut path = PathBuilder::new();
|
||||
if style.border.top > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width, style.border.top) {
|
||||
path.push_rect(r);
|
||||
let mut push_clipped = |x: f32, y: f32, w: f32, h: f32| {
|
||||
let edge = crate::Rect { x, y, width: w, height: h };
|
||||
let edge = match clip { Some(c) => edge.intersect(&c), None => Some(edge) };
|
||||
if let Some(e) = edge {
|
||||
if let Some(r) = Rect::from_xywh(e.x, e.y, e.width, e.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
}
|
||||
};
|
||||
if style.border.top > 0.0 {
|
||||
push_clipped(rect.x, rect.y, rect.width, style.border.top);
|
||||
}
|
||||
if style.border.right > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height);
|
||||
}
|
||||
if style.border.bottom > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom);
|
||||
}
|
||||
if style.border.left > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, style.border.left, rect.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x, rect.y, style.border.left, rect.height);
|
||||
}
|
||||
if let Some(path) = path.finish() {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if name.local.as_ref() == "img" {
|
||||
let src = node.get_attribute("src").unwrap_or("");
|
||||
if src.contains("y18") {
|
||||
let mut paint = tiny_skia::Paint {
|
||||
shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 255, 255, 255)),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(r) = Rect::from_xywh(rect.x - 1.0, rect.y - 1.0, rect.width.max(18.0) + 2.0, rect.height.max(18.0) + 2.0) {
|
||||
pixmap.fill_rect(r, &paint, Transform::identity(), None);
|
||||
if let Some(src) = node.get_attribute("src") {
|
||||
let painted = paint_image(src, base_url, &rect, &mut pixmap, &mut image_cache);
|
||||
// Fall back to alt text only when the image itself did not paint.
|
||||
if !painted {
|
||||
if let Some(alt) = node.get_attribute("alt") {
|
||||
if !alt.is_empty() {
|
||||
draw_text(&mut pixmap, alt, rect.x, rect.y, [0, 0, 0, 255], 12.0, false, clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
paint.shader = tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 102, 0, 255));
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width.max(18.0), rect.height.max(18.0)) {
|
||||
pixmap.fill_rect(r, &paint, Transform::identity(), None);
|
||||
draw_text(&mut pixmap, "Y", rect.x + 4.0, rect.y + 2.0, [255, 255, 255, 255], 14.0, false);
|
||||
}
|
||||
} else if let Some(src) = node.get_attribute("src") {
|
||||
paint_image(src, &rect, &mut pixmap);
|
||||
}
|
||||
}
|
||||
|
||||
if node.get_attribute("class").unwrap_or_default() == "votearrow" {
|
||||
let mut pb = tiny_skia::PathBuilder::new();
|
||||
pb.move_to(rect.x + rect.width * 0.5, rect.y);
|
||||
pb.line_to(rect.x + rect.width, rect.y + rect.height);
|
||||
pb.line_to(rect.x, rect.y + rect.height);
|
||||
pb.close();
|
||||
if let Some(path) = pb.finish() {
|
||||
let mut paint = tiny_skia::Paint::default();
|
||||
paint.set_color_rgba8(130, 130, 130, 255);
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,8 +162,8 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
|
||||
/// Render `tree` at `viewport` to PNG bytes (RGBA 8-bit). Returns None if the
|
||||
/// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`.
|
||||
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32)) -> Option<Vec<u8>> {
|
||||
paint_dom(tree, viewport)?.encode_png().ok()
|
||||
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Vec<u8>> {
|
||||
paint_dom(tree, viewport, base_url)?.encode_png().ok()
|
||||
}
|
||||
|
||||
fn paint_text_node(
|
||||
@@ -179,13 +209,9 @@ fn paint_text_node(
|
||||
Some(r) => r,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
if text.contains("zachtronics") || text.contains("Exapunks") {
|
||||
println!("DEBUG text: {:?} rect: {:?}", text, rect);
|
||||
}
|
||||
|
||||
// Paint text at rect.x, rect.y
|
||||
draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold);
|
||||
|
||||
let clip = laid.clip_rects.get(&nid).copied().flatten();
|
||||
draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold, clip);
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -202,7 +228,14 @@ pub fn measure_text(text: &str, size: f32, is_bold: bool) -> f32 {
|
||||
width
|
||||
}
|
||||
|
||||
fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool) {
|
||||
fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool, clip: Option<crate::Rect>) {
|
||||
// A fully clipped-away run (the common "visually hidden" accessibility
|
||||
// pattern: a 1x1 box with overflow: hidden) paints nothing at all.
|
||||
if let Some(c) = clip {
|
||||
if c.width <= 0.0 || c.height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let font = FontRef::try_from_slice(FONT_BYTES).unwrap();
|
||||
let scale = PxScale::from(size);
|
||||
let scaled_font = font.as_scaled(scale);
|
||||
@@ -210,6 +243,7 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
|
||||
let width = pixmap.width() as i32;
|
||||
let height = pixmap.height() as i32;
|
||||
let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height));
|
||||
let pixels = pixmap.pixels_mut();
|
||||
let (r, g, b, a_full) = (color[0], color[1], color[2], color[3]);
|
||||
|
||||
@@ -225,6 +259,11 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
outlined.draw(|gx, gy, c| {
|
||||
let px = (bounds.min.x + gx as f32) as i32;
|
||||
let py = (bounds.min.y + gy as f32) as i32;
|
||||
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
||||
if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if px >= 0 && px < width && py >= 0 && py < height {
|
||||
let alpha = (a_full as f32 * c) as u8;
|
||||
if alpha > 0 {
|
||||
@@ -263,9 +302,15 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
fn paint_image(
|
||||
src: &str,
|
||||
base_url: Option<&str>,
|
||||
rect: &crate::Rect,
|
||||
pixmap: &mut Pixmap,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> bool {
|
||||
if rect.width <= 0.0 || rect.height <= 0.0 {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let bytes = if src.starts_with("data:image/") {
|
||||
@@ -273,30 +318,69 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.decode(&src[comma_idx + 1..]).ok()
|
||||
} else { None }
|
||||
} else if src.starts_with("http://") || src.starts_with("https://") {
|
||||
ureq::get(src).call().ok().and_then(|resp| {
|
||||
let mut buf = Vec::new();
|
||||
use std::io::Read;
|
||||
resp.into_reader().read_to_end(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
// Resolve relative to the document's base URL: the overwhelming
|
||||
// majority of real markup uses relative image paths ("logo.svg", not
|
||||
// "https://example.com/logo.svg"), so without this every relative
|
||||
// <img> silently fails to fetch, not just SVGs.
|
||||
let resolved = if src.starts_with("http://") || src.starts_with("https://") {
|
||||
Some(src.to_string())
|
||||
} else {
|
||||
base_url
|
||||
.and_then(|b| url::Url::parse(b).ok())
|
||||
.and_then(|base| base.join(src).ok())
|
||||
.map(|u| u.to_string())
|
||||
};
|
||||
match resolved {
|
||||
// The same icon/sprite/background is routinely referenced by
|
||||
// dozens of elements on one page (every story's vote arrow, every
|
||||
// repeated logo); fetch each distinct URL over the network once
|
||||
// per screenshot rather than once per element.
|
||||
Some(url) => cache
|
||||
.entry(url.clone())
|
||||
.or_insert_with(|| {
|
||||
ureq::get(&url).call().ok().and_then(|resp| {
|
||||
let mut buf = Vec::new();
|
||||
use std::io::Read;
|
||||
resp.into_reader().read_to_end(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
})
|
||||
})
|
||||
.clone(),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(bytes) = bytes {
|
||||
if let Ok(img) = image::load_from_memory(&bytes) {
|
||||
let img = img.to_rgba8();
|
||||
let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle);
|
||||
|
||||
let mut raw = resized.into_raw();
|
||||
for pixel in raw.chunks_exact_mut(4) {
|
||||
let a = pixel[3] as u32;
|
||||
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8;
|
||||
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8;
|
||||
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8;
|
||||
}
|
||||
if let Some(img_pixmap) = Pixmap::from_vec(raw, tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32).unwrap()) {
|
||||
let Some(bytes) = bytes else { return false };
|
||||
|
||||
if is_svg(&bytes) {
|
||||
if let Some(svg_pixmap) = render_svg(&bytes, rect.width as u32, rect.height as u32) {
|
||||
pixmap.draw_pixmap(
|
||||
rect.x as i32,
|
||||
rect.y as i32,
|
||||
svg_pixmap.as_ref(),
|
||||
&tiny_skia::PixmapPaint::default(),
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(img) = image::load_from_memory(&bytes) {
|
||||
let img = img.to_rgba8();
|
||||
let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle);
|
||||
|
||||
let mut raw = resized.into_raw();
|
||||
for pixel in raw.chunks_exact_mut(4) {
|
||||
let a = pixel[3] as u32;
|
||||
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8;
|
||||
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8;
|
||||
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8;
|
||||
}
|
||||
if let Some(size) = tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32) {
|
||||
if let Some(img_pixmap) = Pixmap::from_vec(raw, size) {
|
||||
pixmap.draw_pixmap(
|
||||
rect.x as i32,
|
||||
rect.y as i32,
|
||||
@@ -305,9 +389,38 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Sniff SVG content: either an XML/SVG prolog, or a bare `<svg` root tag
|
||||
/// (both are valid, and image responses commonly omit the XML declaration).
|
||||
fn is_svg(bytes: &[u8]) -> bool {
|
||||
let head = &bytes[..bytes.len().min(256)];
|
||||
let text = String::from_utf8_lossy(head);
|
||||
let trimmed = text.trim_start_matches('\u{feff}').trim_start();
|
||||
trimmed.starts_with("<?xml") || trimmed.starts_with("<svg")
|
||||
}
|
||||
|
||||
/// Rasterize SVG bytes to a `width` x `height` pixmap, scaled to fit (matching
|
||||
/// how a replaced element like `<img>` sizes its intrinsic content).
|
||||
fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option<Pixmap> {
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let opts = usvg::Options::default();
|
||||
let tree = usvg::Tree::from_data(bytes, &opts).ok()?;
|
||||
let size = tree.size();
|
||||
if size.width() <= 0.0 || size.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut svg_pixmap = Pixmap::new(width, height)?;
|
||||
let transform = Transform::from_scale(width as f32 / size.width(), height as f32 / size.height());
|
||||
resvg::render(&tree, transform, &mut svg_pixmap.as_mut());
|
||||
Some(svg_pixmap)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -320,7 +433,7 @@ mod tests {
|
||||
let tree = parse_html(
|
||||
"<html><body><div style=\"background-color: #ff0000; width: 100px; height: 80px\"></div></body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
assert_eq!(pixmap.width(), 200);
|
||||
// The red div is laid out at the origin; sample inside it.
|
||||
let inside = pixmap.pixel(10, 10).expect("pixel");
|
||||
@@ -345,7 +458,7 @@ mod tests {
|
||||
</div>\
|
||||
</body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let p = pixmap.pixel(5, 5).expect("pixel");
|
||||
assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p);
|
||||
}
|
||||
@@ -355,7 +468,7 @@ mod tests {
|
||||
let tree = parse_html(
|
||||
"<html><body><div style=\"color: #00ff00; width: 100px; height: 100px\">Hello</div></body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let mut found_green = false;
|
||||
for y in 0..200 {
|
||||
for x in 0..200 {
|
||||
|
||||
@@ -38,9 +38,33 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
|
||||
style.flex_direction = Some(taffy::FlexDirection::Column);
|
||||
style.align_items = Some(taffy::AlignItems::Center);
|
||||
} 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" {
|
||||
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" {
|
||||
style.display = Display::Flex;
|
||||
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) {
|
||||
match name {
|
||||
"display" => match value {
|
||||
"block" => style.display = Display::Block,
|
||||
"flex" => style.display = Display::Flex,
|
||||
"inline" => style.display = Display::Inline,
|
||||
"none" => style.display = Display::None,
|
||||
"none" => style.display = crate::Display::None,
|
||||
"flex" => style.display = crate::Display::Flex,
|
||||
"inline-flex" => style.display = crate::Display::Flex,
|
||||
"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),
|
||||
"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-top" => set_edge(&mut style.margin, Side::Top, 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-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)),
|
||||
"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),
|
||||
"border-color" => style.border_color = parse_color(value),
|
||||
"font-size" => style.font_size = px(value),
|
||||
"font-weight" => style.font_weight = Some(value.to_string()),
|
||||
"text-align" => {
|
||||
if value == "right" {
|
||||
style.justify_content = Some(taffy::JustifyContent::FlexEnd);
|
||||
style.align_items = Some(taffy::AlignItems::FlexEnd);
|
||||
} else if value == "center" {
|
||||
style.justify_content = Some(taffy::JustifyContent::Center);
|
||||
style.align_items = Some(taffy::AlignItems::Center);
|
||||
}
|
||||
// Our engine has no real inline formatting context, so text-align is
|
||||
// approximated the same way as align-items: it positions a block's
|
||||
// (or column-flex's) children along the cross axis. See
|
||||
// `to_taffy_style`'s block-to-flex-column promotion, which is what
|
||||
// makes this actually take effect on plain block elements.
|
||||
"text-align" => match value {
|
||||
"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" => {
|
||||
if value == "center" {
|
||||
style.align_items = Some(taffy::AlignItems::Center);
|
||||
} else if value == "flex-start" || value == "start" {
|
||||
style.align_items = Some(taffy::AlignItems::FlexStart);
|
||||
} else if value == "flex-end" || value == "end" {
|
||||
style.align_items = Some(taffy::AlignItems::FlexEnd);
|
||||
match value {
|
||||
"center" => style.align_items = Some(taffy::AlignItems::Center),
|
||||
"flex-start" | "start" => style.align_items = Some(taffy::AlignItems::FlexStart),
|
||||
"flex-end" | "end" => style.align_items = Some(taffy::AlignItems::FlexEnd),
|
||||
"stretch" => style.align_items = Some(taffy::AlignItems::Stretch),
|
||||
"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
|
||||
/// small set of named colors. Returns None for anything else (transparent).
|
||||
fn parse_color(value: &str) -> Option<[u8; 4]> {
|
||||
/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
|
||||
/// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the
|
||||
/// 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();
|
||||
if let Some(h) = v.strip_prefix('#') {
|
||||
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]),
|
||||
"black" => Some([0, 0, 0, 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]),
|
||||
"green" => Some([0, 128, 0, 255]),
|
||||
"lime" => Some([0, 255, 0, 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]),
|
||||
"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]),
|
||||
_ => None,
|
||||
}
|
||||
@@ -247,6 +561,67 @@ fn token(value: &str) -> Option<&str> {
|
||||
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);
|
||||
/// 3 values are (top, h, bottom); 4 values are (top, right, bottom, left).
|
||||
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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user