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