render: real-world CSS rendering parity
Colors: oklch()/oklab() and color-mix(); linear-gradient backgrounds. Grid: named-line placement, repeat() expansion, drop whitespace-only grid items; upgrade taffy 0.7 -> 0.12 so aspect-ratio images in auto-height rows no longer collapse to slivers. CSS: @layer, nesting, @property, :is()/:where(), clamp(), @media not all; source/track/param/area and svg desc/metadata as display:none. Images: webp + intrinsic sizing, srcset/picture, browser UA + Accept for CDN fetches, grey placeholder on failure; width/height HTML attributes rank below author CSS with aspect-ratio preserved for auto axes.
This commit is contained in:
1 parent
88313178b2
commit
2652ee64a4
17 files changed
+4425
-311
No files matched your search
Generated
+292
-62
@@ -229,7 +229,7 @@ version = "0.71.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"cexpr",
|
"cexpr",
|
||||||
"clang-sys",
|
"clang-sys",
|
||||||
"itertools",
|
"itertools",
|
||||||
@@ -238,7 +238,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.2",
|
||||||
"shlex 1.3.0",
|
"shlex 1.3.0",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
@@ -249,14 +249,14 @@ version = "0.72.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"cexpr",
|
"cexpr",
|
||||||
"clang-sys",
|
"clang-sys",
|
||||||
"itertools",
|
"itertools",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.2",
|
||||||
"shlex 1.3.0",
|
"shlex 1.3.0",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
@@ -276,12 +276,6 @@ version = "0.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitflags"
|
|
||||||
version = "1.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.0"
|
version = "2.13.0"
|
||||||
@@ -345,7 +339,7 @@ version = "0.5.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2c5e60b8c8d282c86360cab651ded04ab0335a7b5390c8d34145cbeab8cacf5f"
|
checksum = "2c5e60b8c8d282c86360cab651ded04ab0335a7b5390c8d34145cbeab8cacf5f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"btls-sys",
|
"btls-sys",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -375,6 +369,20 @@ name = "bytemuck"
|
|||||||
version = "1.25.0"
|
version = "1.25.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck_derive"
|
||||||
|
version = "1.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
@@ -573,6 +581,29 @@ dependencies = [
|
|||||||
"libm",
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cosmic-text"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"fontdb 0.16.2",
|
||||||
|
"log",
|
||||||
|
"rangemap",
|
||||||
|
"rayon",
|
||||||
|
"rustc-hash 1.1.0",
|
||||||
|
"rustybuzz 0.14.1",
|
||||||
|
"self_cell",
|
||||||
|
"swash",
|
||||||
|
"sys-locale",
|
||||||
|
"ttf-parser 0.21.1",
|
||||||
|
"unicode-bidi",
|
||||||
|
"unicode-linebreak",
|
||||||
|
"unicode-script",
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -591,6 +622,31 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-deque"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-epoch"
|
||||||
|
version = "0.9.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -915,6 +971,28 @@ 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 = "font-types"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fontdb"
|
||||||
|
version = "0.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"memmap2",
|
||||||
|
"slotmap",
|
||||||
|
"tinyvec",
|
||||||
|
"ttf-parser 0.20.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fontdb"
|
name = "fontdb"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@@ -924,7 +1002,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"slotmap",
|
"slotmap",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"ttf-parser",
|
"ttf-parser 0.25.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1170,9 +1248,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "grid"
|
name = "grid"
|
||||||
version = "0.15.0"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "36119f3a540b086b4e436bb2b588cf98a68863470e0e880f4d0842f112a3183a"
|
checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gzip-header"
|
name = "gzip-header"
|
||||||
@@ -1482,16 +1560,18 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "image"
|
name = "image"
|
||||||
version = "0.24.9"
|
version = "0.25.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder",
|
"byteorder-lite",
|
||||||
"color_quant",
|
"image-webp",
|
||||||
"jpeg-decoder",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png 0.17.16",
|
"png",
|
||||||
|
"zune-core",
|
||||||
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1569,12 +1649,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jpeg-decoder"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.102"
|
version = "0.3.102"
|
||||||
@@ -1726,6 +1800,15 @@ version = "2.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memmap2"
|
||||||
|
version = "0.9.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -1753,6 +1836,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -1972,6 +2065,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"base64",
|
"base64",
|
||||||
|
"cosmic-text",
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"image",
|
"image",
|
||||||
"obscura-dom",
|
"obscura-dom",
|
||||||
@@ -2024,7 +2118,7 @@ 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 = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
|
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ttf-parser",
|
"ttf-parser 0.25.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2172,26 +2266,13 @@ version = "0.3.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "png"
|
|
||||||
version = "0.17.16"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"crc32fast",
|
|
||||||
"fdeflate",
|
|
||||||
"flate2",
|
|
||||||
"miniz_oxide",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"fdeflate",
|
"fdeflate",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2291,6 +2372,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -2308,7 +2395,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn-proto",
|
"quinn-proto",
|
||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.2",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -2328,7 +2415,7 @@ dependencies = [
|
|||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.2",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
@@ -2426,6 +2513,32 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rangemap"
|
||||||
|
version = "1.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"rayon-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon-core"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-deque",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rcgen"
|
name = "rcgen"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -2439,13 +2552,23 @@ dependencies = [
|
|||||||
"yasna",
|
"yasna",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "read-fonts"
|
||||||
|
version = "0.22.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"font-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2564,6 +2687,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -2576,7 +2705,7 @@ version = "0.38.44"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
@@ -2589,7 +2718,7 @@ version = "1.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
@@ -2638,20 +2767,37 @@ 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.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytemuck",
|
||||||
|
"libm",
|
||||||
|
"smallvec",
|
||||||
|
"ttf-parser 0.21.1",
|
||||||
|
"unicode-bidi-mirroring 0.2.0",
|
||||||
|
"unicode-ccc 0.2.0",
|
||||||
|
"unicode-properties",
|
||||||
|
"unicode-script",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustybuzz"
|
name = "rustybuzz"
|
||||||
version = "0.20.1"
|
version = "0.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
|
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"core_maths",
|
"core_maths",
|
||||||
"log",
|
"log",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"ttf-parser",
|
"ttf-parser 0.25.1",
|
||||||
"unicode-bidi-mirroring",
|
"unicode-bidi-mirroring 0.4.0",
|
||||||
"unicode-ccc",
|
"unicode-ccc 0.4.0",
|
||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
"unicode-script",
|
"unicode-script",
|
||||||
]
|
]
|
||||||
@@ -2674,7 +2820,7 @@ version = "0.26.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"cssparser",
|
"cssparser",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"fxhash",
|
"fxhash",
|
||||||
@@ -2687,6 +2833,12 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "self_cell"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
@@ -2846,6 +2998,16 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "skrifa"
|
||||||
|
version = "0.22.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"read-fonts",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2888,7 +3050,7 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"debugid",
|
"debugid",
|
||||||
"if_chain",
|
"if_chain",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"unicode-id-start",
|
"unicode-id-start",
|
||||||
@@ -2990,6 +3152,17 @@ dependencies = [
|
|||||||
"siphasher",
|
"siphasher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swash"
|
||||||
|
version = "0.1.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2"
|
||||||
|
dependencies = [
|
||||||
|
"skrifa",
|
||||||
|
"yazi",
|
||||||
|
"zeno",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -3021,6 +3194,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sys-locale"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sys_traits"
|
name = "sys_traits"
|
||||||
version = "0.1.28"
|
version = "0.1.28"
|
||||||
@@ -3043,9 +3225,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "taffy"
|
name = "taffy"
|
||||||
version = "0.7.7"
|
version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab4f4d046dd956a47a7e1a2947083d7ac3e6aa3cfaaead36173ceaa5ab11878c"
|
checksum = "73afc801dd6bd47529eaa7c7e90557f107527d1b7c9c7ed7d7803c7b8d0c357f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"grid",
|
"grid",
|
||||||
@@ -3162,7 +3344,7 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"log",
|
"log",
|
||||||
"png 0.18.1",
|
"png",
|
||||||
"tiny-skia-path",
|
"tiny-skia-path",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3309,7 +3491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-compression",
|
"async-compression",
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -3404,6 +3586,18 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ttf-parser"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ttf-parser"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ttf-parser"
|
name = "ttf-parser"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@@ -3462,12 +3656,24 @@ version = "0.3.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-bidi-mirroring"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi-mirroring"
|
name = "unicode-bidi-mirroring"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
|
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ccc"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ccc"
|
name = "unicode-ccc"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -3486,6 +3692,12 @@ 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-linebreak"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-properties"
|
name = "unicode-properties"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3498,6 +3710,12 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-vo"
|
name = "unicode-vo"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3563,19 +3781,19 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"data-url",
|
"data-url",
|
||||||
"flate2",
|
"flate2",
|
||||||
"fontdb",
|
"fontdb 0.23.0",
|
||||||
"imagesize",
|
"imagesize",
|
||||||
"kurbo",
|
"kurbo",
|
||||||
"log",
|
"log",
|
||||||
"pico-args",
|
"pico-args",
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
"rustybuzz",
|
"rustybuzz 0.20.1",
|
||||||
"simplecss",
|
"simplecss",
|
||||||
"siphasher",
|
"siphasher",
|
||||||
"strict-num",
|
"strict-num",
|
||||||
"svgtypes",
|
"svgtypes",
|
||||||
"tiny-skia-path",
|
"tiny-skia-path",
|
||||||
"ttf-parser",
|
"ttf-parser 0.25.1",
|
||||||
"unicode-bidi",
|
"unicode-bidi",
|
||||||
"unicode-script",
|
"unicode-script",
|
||||||
"unicode-vo",
|
"unicode-vo",
|
||||||
@@ -3618,7 +3836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595"
|
checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen 0.71.1",
|
"bindgen 0.71.1",
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"fslock",
|
"fslock",
|
||||||
"gzip-header",
|
"gzip-header",
|
||||||
"home",
|
"home",
|
||||||
@@ -3771,7 +3989,7 @@ version = "0.244.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.15.5",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4105,7 +4323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bitflags 2.13.0",
|
"bitflags",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4241,6 +4459,12 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yazi"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -4264,6 +4488,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeno"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.52"
|
version = "0.8.52"
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ selectors = "0.26"
|
|||||||
servo_arc = "0.4"
|
servo_arc = "0.4"
|
||||||
cssparser = "0.34"
|
cssparser = "0.34"
|
||||||
# Layout engine for the optional render layer (obscura-render). Pure Rust.
|
# Layout engine for the optional render layer (obscura-render). Pure Rust.
|
||||||
taffy = "0.7"
|
taffy = "0.12"
|
||||||
# CPU-only pure-Rust 2D rasterizer for the render layer's paint step.
|
# CPU-only pure-Rust 2D rasterizer for the render layer's paint step.
|
||||||
tiny-skia = "0.12"
|
tiny-skia = "0.12"
|
||||||
# Pure-Rust glyph rasterizer for the render layer's text step.
|
# Pure-Rust glyph rasterizer for the render layer's text step.
|
||||||
|
|||||||
@@ -655,9 +655,10 @@ mod tests {
|
|||||||
!err.contains("Unknown Page method"),
|
!err.contains("Unknown Page method"),
|
||||||
"captureScreenshot must NOT fall through to the catch-all: {err}"
|
"captureScreenshot must NOT fall through to the catch-all: {err}"
|
||||||
);
|
);
|
||||||
|
#[cfg(not(feature = "render"))]
|
||||||
assert!(
|
assert!(
|
||||||
err.contains("not supported by Obscura"),
|
err.contains("requires a build with the render feature"),
|
||||||
"error must clearly state screenshot is unsupported: {err}"
|
"error must clearly state screenshot needs the render feature: {err}"
|
||||||
);
|
);
|
||||||
// Same for the MHTML snapshot sibling method.
|
// Same for the MHTML snapshot sibling method.
|
||||||
let err2 = handle("captureSnapshot", &json!({}), &mut ctx, &None)
|
let err2 = handle("captureSnapshot", &json!({}), &mut ctx, &None)
|
||||||
|
|||||||
@@ -659,7 +659,11 @@ async fn run_fetch(
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
|
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
|
||||||
match page.screenshot((1280.0, 720.0)) {
|
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
||||||
|
// capture below-the-fold content in one shot).
|
||||||
|
let shot_w = std::env::var("OBSCURA_SHOT_W").ok().and_then(|v| v.parse::<f32>().ok()).unwrap_or(1280.0);
|
||||||
|
let shot_h = std::env::var("OBSCURA_SHOT_H").ok().and_then(|v| v.parse::<f32>().ok()).unwrap_or(720.0);
|
||||||
|
match page.screenshot((shot_w, shot_h)) {
|
||||||
Some(bytes) => std::fs::write(path, &bytes)?,
|
Some(bytes) => std::fs::write(path, &bytes)?,
|
||||||
None => anyhow::bail!("screenshot failed: page has no DOM to render"),
|
None => anyhow::bail!("screenshot failed: page has no DOM to render"),
|
||||||
}
|
}
|
||||||
@@ -1819,6 +1823,7 @@ mod tests {
|
|||||||
quiet: true,
|
quiet: true,
|
||||||
output: None,
|
output: None,
|
||||||
storage_dir: None,
|
storage_dir: None,
|
||||||
|
screenshot: None,
|
||||||
});
|
});
|
||||||
assert!(is_quiet_command(&cmd));
|
assert!(is_quiet_command(&cmd));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,17 @@ impl<'i> parser::Parser<'i> for ObscuraSelectorParser {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow `:is()` and `:where()`. The selectors crate gates these on this hook
|
||||||
|
// (default false), so without it every `:where(...)`/`:is(...)` selector
|
||||||
|
// failed to parse and was dropped, discarding those rules from both the
|
||||||
|
// render cascade and querySelectorAll. Tailwind's preflight and most modern
|
||||||
|
// resets wrap their rules in `:where(...)` for zero specificity, so this
|
||||||
|
// blanked large parts of many sites. Matching (Component::Is/Where) is built
|
||||||
|
// into the crate, so enabling parsing is sufficient.
|
||||||
|
fn parse_is_and_where(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_non_ts_pseudo_class(
|
fn parse_non_ts_pseudo_class(
|
||||||
&self,
|
&self,
|
||||||
_location: cssparser::SourceLocation,
|
_location: cssparser::SourceLocation,
|
||||||
|
|||||||
Vendored
+6
-2
@@ -4527,8 +4527,12 @@ if (typeof TextDecoder === 'undefined') {
|
|||||||
globalThis.matchMedia = _markNative(function matchMedia(q) {
|
globalThis.matchMedia = _markNative(function matchMedia(q) {
|
||||||
var s = (q || '').toLowerCase().replace(/\s+/g, '');
|
var s = (q || '').toLowerCase().replace(/\s+/g, '');
|
||||||
var matches = false;
|
var matches = false;
|
||||||
if (s.includes('prefers-color-scheme:light')) matches = false;
|
// Match headless Chromium's default: light color scheme. Reporting dark
|
||||||
else if (s.includes('prefers-color-scheme:dark')) matches = true;
|
// here made every JS-driven site (mdBook, docs, apps) switch itself into a
|
||||||
|
// dark theme, rendering nothing like a normal headless-Chrome screenshot.
|
||||||
|
if (s.includes('prefers-color-scheme:dark')) matches = false;
|
||||||
|
else if (s.includes('prefers-color-scheme:light')) matches = true;
|
||||||
|
else if (s.includes('prefers-color-scheme:no-preference')) matches = false;
|
||||||
else if (s.includes('prefers-reduced-motion:no-preference')) matches = true;
|
else if (s.includes('prefers-reduced-motion:no-preference')) matches = true;
|
||||||
else if (s.includes('prefers-reduced-motion:reduce')) matches = false;
|
else if (s.includes('prefers-reduced-motion:reduce')) matches = false;
|
||||||
else if (s.includes('any-pointer:fine')) matches = true;
|
else if (s.includes('any-pointer:fine')) matches = true;
|
||||||
|
|||||||
@@ -8,16 +8,21 @@ description = "Optional scoped render layer for Obscura: layout (taffy) and, wit
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
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 (cosmic-text
|
||||||
|
# shaping + line breaking, ab_glyph fallback) 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", "dep:resvg", "dep:usvg", "dep:url"]
|
paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
taffy = { workspace = true }
|
taffy = { workspace = true }
|
||||||
obscura-dom = { workspace = true }
|
obscura-dom = { workspace = true }
|
||||||
tiny-skia = { workspace = true, optional = true }
|
tiny-skia = { workspace = true, optional = true }
|
||||||
ab_glyph = { workspace = true, optional = true }
|
ab_glyph = { workspace = true, optional = true }
|
||||||
image = { version = "0.24", default-features = false, features = ["jpeg", "png", "webp"], optional = true }
|
# Real inline text layout: HarfBuzz-class shaping (rustybuzz) + UAX#14 line
|
||||||
|
# breaking, the same algorithms browsers use. Built with only our embedded
|
||||||
|
# fonts (no system-font scan) so layout stays deterministic across hosts.
|
||||||
|
cosmic-text = { version = "=0.12.1", default-features = false, features = ["std", "swash"], optional = true }
|
||||||
|
image = { version = "0.25", 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
|
# SVG rasterization for <img src="*.svg">, inline <svg>, and background-image
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,17 @@ pub struct Stylesheet {
|
|||||||
by_class: HashMap<String, Vec<usize>>,
|
by_class: HashMap<String, Vec<usize>>,
|
||||||
by_local: HashMap<String, Vec<usize>>,
|
by_local: HashMap<String, Vec<usize>>,
|
||||||
universal: Vec<usize>,
|
universal: Vec<usize>,
|
||||||
|
/// `sel::before{content:"..."}` / `sel::after{...}` rules with a plain
|
||||||
|
/// string-literal `content`, matched separately from the main cascade:
|
||||||
|
/// our selector engine has no pseudo-element matching machinery, but a
|
||||||
|
/// `::before`/`::after` selector's *base* (everything before the
|
||||||
|
/// pseudo-element) is an ordinary selector we can already compile and
|
||||||
|
/// match. Extremely common on Wikipedia (`.hlist`/`.cslist` render as
|
||||||
|
/// comma-separated lists purely via `li::before{content:", "}`); without
|
||||||
|
/// this, adjacent list items with no real whitespace between them in the
|
||||||
|
/// DOM run together with no separator at all.
|
||||||
|
before_content: Vec<(CompiledSelector, String)>,
|
||||||
|
after_content: Vec<(CompiledSelector, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stylesheet {
|
impl Stylesheet {
|
||||||
@@ -39,10 +50,25 @@ impl Stylesheet {
|
|||||||
by_class: HashMap::new(),
|
by_class: HashMap::new(),
|
||||||
by_local: HashMap::new(),
|
by_local: HashMap::new(),
|
||||||
universal: Vec::new(),
|
universal: Vec::new(),
|
||||||
|
before_content: Vec::new(),
|
||||||
|
after_content: Vec::new(),
|
||||||
};
|
};
|
||||||
let mut order = 0usize;
|
let mut order = 0usize;
|
||||||
for src in sources {
|
for src in sources {
|
||||||
for (selector, decls) in parse_stylesheet(src) {
|
for (selector, decls) in parse_stylesheet(src) {
|
||||||
|
let sel_trim = selector.trim();
|
||||||
|
if let Some(base) = strip_pseudo_element(sel_trim, "before") {
|
||||||
|
if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) {
|
||||||
|
sheet.before_content.push((sel, content));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(base) = strip_pseudo_element(sel_trim, "after") {
|
||||||
|
if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) {
|
||||||
|
sheet.after_content.push((sel, content));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Some(sel) = tree.compile_rule_selector(&selector) else { continue };
|
let Some(sel) = tree.compile_rule_selector(&selector) else { continue };
|
||||||
let idx = sheet.rules.len();
|
let idx = sheet.rules.len();
|
||||||
match sel.key() {
|
match sel.key() {
|
||||||
@@ -58,6 +84,28 @@ impl Stylesheet {
|
|||||||
sheet
|
sheet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The literal text (if any) that `::before`/`::after` rules inject for
|
||||||
|
/// `nid`, as `(before, after)`. Last matching rule wins, approximating
|
||||||
|
/// cascade order without tracking specificity for this small side table.
|
||||||
|
pub fn pseudo_content(&self, tree: &DomTree, matcher: &mut Matcher, nid: NodeId) -> (Option<String>, Option<String>) {
|
||||||
|
if self.before_content.is_empty() && self.after_content.is_empty() {
|
||||||
|
return (None, None);
|
||||||
|
}
|
||||||
|
let mut before = None;
|
||||||
|
for (sel, content) in &self.before_content {
|
||||||
|
if matcher.matches(tree, nid, sel) {
|
||||||
|
before = Some(content.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut after = None;
|
||||||
|
for (sel, content) in &self.after_content {
|
||||||
|
if matcher.matches(tree, nid, sel) {
|
||||||
|
after = Some(content.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(before, after)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.rules.is_empty()
|
self.rules.is_empty()
|
||||||
}
|
}
|
||||||
@@ -70,6 +118,10 @@ impl Stylesheet {
|
|||||||
/// Apply every author rule that matches `nid` to `style`, in cascade order
|
/// Apply every author rule that matches `nid` to `style`, in cascade order
|
||||||
/// (ascending specificity, then source order, so the winner is applied last).
|
/// (ascending specificity, then source order, so the winner is applied last).
|
||||||
/// `id`, `classes`, and `local` are the element's precomputed keys.
|
/// `id`, `classes`, and `local` are the element's precomputed keys.
|
||||||
|
/// `parent_props` is the element's inherited custom-property map. Returns
|
||||||
|
/// `Some(map)` (parent + this element's own `--x` declarations) when this
|
||||||
|
/// element declares any custom properties, so the caller can thread the
|
||||||
|
/// richer map to descendants; `None` means "reuse the parent's map".
|
||||||
pub fn apply(
|
pub fn apply(
|
||||||
&self,
|
&self,
|
||||||
tree: &DomTree,
|
tree: &DomTree,
|
||||||
@@ -79,7 +131,8 @@ impl Stylesheet {
|
|||||||
classes: &[String],
|
classes: &[String],
|
||||||
local: &str,
|
local: &str,
|
||||||
style: &mut LayoutStyle,
|
style: &mut LayoutStyle,
|
||||||
) {
|
parent_props: &HashMap<String, String>,
|
||||||
|
) -> Option<HashMap<String, String>> {
|
||||||
// (specificity, order, rule index) for each matching rule.
|
// (specificity, order, rule index) for each matching rule.
|
||||||
let mut matched: Vec<(u32, usize, usize)> = Vec::new();
|
let mut matched: Vec<(u32, usize, usize)> = Vec::new();
|
||||||
let mut consider = |bucket: Option<&Vec<usize>>, matched: &mut Vec<(u32, usize, usize)>| {
|
let mut consider = |bucket: Option<&Vec<usize>>, matched: &mut Vec<(u32, usize, usize)>| {
|
||||||
@@ -105,18 +158,185 @@ impl Stylesheet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if matched.is_empty() {
|
if matched.is_empty() {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||||
|
|
||||||
|
// Pass 1: collect this element's own custom properties (`--x: value`),
|
||||||
|
// in cascade order (last wins), layered over the inherited map. Custom
|
||||||
|
// properties cascade fully before any `var()` is substituted.
|
||||||
|
let mut own: Vec<(String, String)> = Vec::new();
|
||||||
|
for &(_, _, i) in &matched {
|
||||||
|
for decl in crate::style::split_declarations(&self.rules[i].decls) {
|
||||||
|
if let Some((name, val)) = decl.split_once(':') {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.starts_with("--") && name.len() > 2 {
|
||||||
|
own.push((name.to_string(), val.trim().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let effective = if own.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut m = parent_props.clone();
|
||||||
|
for (k, v) in own {
|
||||||
|
m.insert(k, v);
|
||||||
|
}
|
||||||
|
Some(m)
|
||||||
|
};
|
||||||
|
let props = effective.as_ref().unwrap_or(parent_props);
|
||||||
|
|
||||||
|
// Pass 2: apply normal declarations with `var()` substituted against
|
||||||
|
// the resolved custom-property map.
|
||||||
for (_, _, i) in matched {
|
for (_, _, i) in matched {
|
||||||
crate::style::apply_inline(style, &self.rules[i].decls);
|
let expanded = substitute_vars(&self.rules[i].decls, props, 0);
|
||||||
|
crate::style::apply_inline(style, &expanded);
|
||||||
|
}
|
||||||
|
effective
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Substitute every `var(--name, fallback?)` in `input` with its resolved
|
||||||
|
/// value from `props` (recursively, so a token that itself references another
|
||||||
|
/// token resolves; a depth cap guards against reference cycles). A missing
|
||||||
|
/// variable with no fallback expands to empty, which makes the declaration
|
||||||
|
/// invalid and dropped, exactly as CSS specifies.
|
||||||
|
pub(crate) fn substitute_vars(input: &str, props: &HashMap<String, String>, depth: u8) -> String {
|
||||||
|
if depth > 16 || !input.contains("var(") {
|
||||||
|
return input.to_string();
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut rest = input;
|
||||||
|
while let Some(pos) = rest.find("var(") {
|
||||||
|
out.push_str(&rest[..pos]);
|
||||||
|
let after = &rest[pos + 4..];
|
||||||
|
// Matching close paren, respecting nesting.
|
||||||
|
let mut d = 1i32;
|
||||||
|
let mut end = None;
|
||||||
|
for (i, ch) in after.char_indices() {
|
||||||
|
match ch {
|
||||||
|
'(' => d += 1,
|
||||||
|
')' => {
|
||||||
|
d -= 1;
|
||||||
|
if d == 0 {
|
||||||
|
end = Some(i);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(end) = end else {
|
||||||
|
out.push_str(&rest[pos..]);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let inner = &after[..end];
|
||||||
|
let (name, fallback) = match inner.split_once(',') {
|
||||||
|
Some((n, f)) => (n.trim(), Some(f.trim())),
|
||||||
|
None => (inner.trim(), None),
|
||||||
|
};
|
||||||
|
let replacement = match props.get(name) {
|
||||||
|
Some(v) => substitute_vars(v, props, depth + 1),
|
||||||
|
None => fallback.map(|f| substitute_vars(f, props, depth + 1)).unwrap_or_default(),
|
||||||
|
};
|
||||||
|
out.push_str(&replacement);
|
||||||
|
rest = &after[end + 1..];
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `selector`'s rightmost compound is the pseudo-element `which`
|
||||||
|
/// (`"before"` or `"after"`, matching either the modern `::` or legacy `:`
|
||||||
|
/// form), return everything before it, trimmed. Only a trailing
|
||||||
|
/// pseudo-element is handled: `li::before` strips to `li`, but a selector
|
||||||
|
/// that uses `::before` as anything other than the final component (not
|
||||||
|
/// valid CSS in the first place) is left alone.
|
||||||
|
fn strip_pseudo_element<'a>(selector: &'a str, which: &str) -> Option<&'a str> {
|
||||||
|
for prefix in ["::", ":"] {
|
||||||
|
let suffix = format!("{prefix}{which}");
|
||||||
|
if let Some(base) = selector.strip_suffix(&suffix) {
|
||||||
|
if !base.is_empty() {
|
||||||
|
return Some(base.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull a plain quoted-string `content` value out of a declaration list
|
||||||
|
/// (`content: ", "` -> `Some(", ")`). Deliberately narrow: real `content`
|
||||||
|
/// also accepts `counter()`, `attr()`, `url()` and concatenations of these,
|
||||||
|
/// none of which apply here (no counters or `attr()`-driven content on the
|
||||||
|
/// pages this targets), so anything other than a single quoted string is
|
||||||
|
/// left unhandled rather than guessed at. When `content` is declared more
|
||||||
|
/// than once (the `content: X; content: X / Y` accessible-alt-text pattern),
|
||||||
|
/// the last one wins, matching normal CSS declaration order.
|
||||||
|
fn extract_string_content(decls: &str) -> Option<String> {
|
||||||
|
let mut result = None;
|
||||||
|
let mut search_from = 0;
|
||||||
|
while let Some(rel_idx) = decls[search_from..].find("content") {
|
||||||
|
let idx = search_from + rel_idx;
|
||||||
|
search_from = idx + "content".len();
|
||||||
|
let after = decls[search_from..].trim_start();
|
||||||
|
let Some(after_colon) = after.strip_prefix(':') else { continue };
|
||||||
|
let after_colon = after_colon.trim_start();
|
||||||
|
let Some(quote) = after_colon.chars().next() else { continue };
|
||||||
|
if quote != '"' && quote != '\'' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rest = &after_colon[quote.len_utf8()..];
|
||||||
|
let Some(end) = rest.find(quote) else { continue };
|
||||||
|
result = Some(unescape_css_string(&rest[..end]));
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode CSS string escapes: `\` followed by 1-6 hex digits is a Unicode
|
||||||
|
/// code point (`\200B` -> U+200B ZERO WIDTH SPACE, ubiquitous in generated
|
||||||
|
/// `content` for accessible section-edit-link brackets and similar), with a
|
||||||
|
/// single trailing whitespace character consumed as the escape's own
|
||||||
|
/// terminator per the CSS spec rather than treated as literal content;
|
||||||
|
/// anything else after a backslash (`\"`, `\\`) is a literal escaped
|
||||||
|
/// character. Without this, a hex escape prints as its own literal digits.
|
||||||
|
fn unescape_css_string(s: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c != '\\' {
|
||||||
|
out.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut hex = String::new();
|
||||||
|
while hex.len() < 6 {
|
||||||
|
match chars.peek() {
|
||||||
|
Some(h) if h.is_ascii_hexdigit() => {
|
||||||
|
hex.push(*h);
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hex.is_empty() {
|
||||||
|
if matches!(chars.peek(), Some(next) if next.is_whitespace()) {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||||
|
out.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(next) = chars.next() {
|
||||||
|
out.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a stylesheet into `(selector, declarations)` rules. Handles nested
|
/// Split a stylesheet into `(selector, declarations)` rules. Handles nested
|
||||||
/// braces, `/* comments */`, and the at-rules that carry ordinary rules inside
|
/// braces, `/* comments */`, and the at-rules that carry ordinary rules inside
|
||||||
/// (`@media`, `@supports`); other at-rules (`@font-face`, `@keyframes`, ...) are
|
/// (`@media`, `@supports`, `@layer`); other at-rules (`@font-face`, `@keyframes`, ...) are
|
||||||
/// skipped since they do not contribute layout-relevant declarations here.
|
/// skipped since they do not contribute layout-relevant declarations here.
|
||||||
pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
||||||
let mut rules = Vec::new();
|
let mut rules = Vec::new();
|
||||||
@@ -145,6 +365,12 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
|||||||
current_decls.push(c);
|
current_decls.push(c);
|
||||||
}
|
}
|
||||||
block_depth += 1;
|
block_depth += 1;
|
||||||
|
} else if c == '}' && block_depth == 0 {
|
||||||
|
// Stray top-level close brace (unbalanced author CSS; remoteok.com
|
||||||
|
// ships one mid-sheet). Browsers error-recover and keep parsing;
|
||||||
|
// without this block_depth goes negative and the state machine
|
||||||
|
// inverts, scrambling and losing every rule in the rest of the sheet.
|
||||||
|
current_selector.clear();
|
||||||
} else if c == '}' {
|
} else if c == '}' {
|
||||||
block_depth -= 1;
|
block_depth -= 1;
|
||||||
if block_depth == 0 {
|
if block_depth == 0 {
|
||||||
@@ -153,18 +379,23 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
|||||||
if let Some(at) = sel.strip_prefix('@') {
|
if let Some(at) = sel.strip_prefix('@') {
|
||||||
flush_at_rule(at, sel, decls, &mut rules);
|
flush_at_rule(at, sel, decls, &mut rules);
|
||||||
} else {
|
} else {
|
||||||
for s in sel.split(',') {
|
// The body may contain nested rules (CSS Nesting, ubiquitous
|
||||||
let s = s.trim();
|
// in Tailwind v4 / modern frameworks: `.a{ &:hover{} .b{} }`).
|
||||||
if !s.is_empty() {
|
// Flatten them against this selector; denest also handles the
|
||||||
rules.push((s.to_string(), decls.to_string()));
|
// no-nesting case (just emits the rule's own declarations).
|
||||||
}
|
denest(sel, decls, &mut rules);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
current_selector.clear();
|
current_selector.clear();
|
||||||
current_decls.clear();
|
current_decls.clear();
|
||||||
} else {
|
} else {
|
||||||
current_decls.push(c);
|
current_decls.push(c);
|
||||||
}
|
}
|
||||||
|
} else if c == ';' && block_depth == 0 {
|
||||||
|
// A `;` at the top level terminates an at-statement that carries no
|
||||||
|
// rules (`@layer a, b;`, `@import ...;`, `@charset ...;`). Discard
|
||||||
|
// the buffered text so it cannot bleed into the next rule's selector
|
||||||
|
// and take a real rule down with it.
|
||||||
|
current_selector.clear();
|
||||||
} else if block_depth > 0 {
|
} else if block_depth > 0 {
|
||||||
current_decls.push(c);
|
current_decls.push(c);
|
||||||
} else {
|
} else {
|
||||||
@@ -183,6 +414,33 @@ fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, Stri
|
|||||||
}
|
}
|
||||||
} else if at.starts_with("supports") {
|
} else if at.starts_with("supports") {
|
||||||
rules.extend(parse_stylesheet(inner));
|
rules.extend(parse_stylesheet(inner));
|
||||||
|
} else if at.starts_with("property") {
|
||||||
|
// `@property --x { syntax:..; initial-value: V }` declares a custom
|
||||||
|
// property and its default. Register the initial-value as a `:root`
|
||||||
|
// declaration so `var(--x)` resolves when nothing else sets it. Tailwind
|
||||||
|
// v4 and modern frameworks define theme tokens this way; dropping them
|
||||||
|
// left those `var()`s (page/section backgrounds, colors) unresolved.
|
||||||
|
let name = at["property".len()..].trim();
|
||||||
|
if name.starts_with("--") {
|
||||||
|
for decl in inner.split(';') {
|
||||||
|
if let Some((k, v)) = decl.split_once(':') {
|
||||||
|
if k.trim().eq_ignore_ascii_case("initial-value") && !v.trim().is_empty() {
|
||||||
|
rules.push((":root".to_string(), format!("{}: {}", name, v.trim())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if at.starts_with("layer") {
|
||||||
|
// Cascade layers: `@layer name { ... }` wraps ordinary rules. We do not
|
||||||
|
// model layer priority (real CSS ranks unlayered above layered and
|
||||||
|
// later layers above earlier); just flatten the body in source order so
|
||||||
|
// the (specificity, source-order) cascade applies it. Tailwind/UnoCSS,
|
||||||
|
// Nuxt UI and similar wrap nearly all their CSS, including the `:root`
|
||||||
|
// design tokens and background/color utilities, in `@layer`; dropping it
|
||||||
|
// left whole pages unstyled (white backgrounds, collapsed layout). The
|
||||||
|
// `@layer a, b;` ordering-statement form has no block and is discarded
|
||||||
|
// by parse_stylesheet's top-level `;` handling.
|
||||||
|
rules.extend(parse_stylesheet(inner));
|
||||||
}
|
}
|
||||||
// Other at-rules (@font-face, @keyframes, @import, ...) carry no
|
// Other at-rules (@font-face, @keyframes, @import, ...) carry no
|
||||||
// layout-relevant rules for us, so drop them.
|
// layout-relevant rules for us, so drop them.
|
||||||
@@ -195,12 +453,40 @@ fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, Stri
|
|||||||
/// strips whitespace before scanning: CSS gives no semantic meaning to spaces
|
/// strips whitespace before scanning: CSS gives no semantic meaning to spaces
|
||||||
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
|
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
|
||||||
/// than special-case every formatting variant a site might use.
|
/// than special-case every formatting variant a site might use.
|
||||||
fn media_query_applies(query: &str) -> bool {
|
pub(crate) fn media_query_applies(query: &str) -> bool {
|
||||||
const VIEWPORT_W: f32 = 1280.0;
|
const VIEWPORT_W: f32 = 1280.0;
|
||||||
if query.contains("print") {
|
if query.contains("print") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let compact: String = query.chars().filter(|c| !c.is_whitespace()).collect();
|
let compact: String = query.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect();
|
||||||
|
|
||||||
|
// A `not all` / `not all and (...)` query is a browser-targeting hack (most
|
||||||
|
// commonly the Safari-only flex-gap fallback `@media not all and
|
||||||
|
// (min-resolution:.001dpcm)`, which flips containers from flex to grid); it
|
||||||
|
// does not apply in a Chromium-like context.
|
||||||
|
if compact.starts_with("notall") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color-scheme: we render the light (default) context. A site's
|
||||||
|
// `@media (prefers-color-scheme: dark)` block must NOT apply on top of its
|
||||||
|
// light defaults (that is what was leaking dark backgrounds, e.g. near
|
||||||
|
// black inline <code>); a `:light` block should apply.
|
||||||
|
if compact.contains("prefers-color-scheme:dark") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Reduced-motion / high-contrast / inverted: default (no preference).
|
||||||
|
if compact.contains("prefers-reduced-motion:reduce")
|
||||||
|
|| compact.contains("prefers-contrast:more")
|
||||||
|
|| compact.contains("prefers-contrast:less")
|
||||||
|
|| compact.contains("inverted-colors:inverted")
|
||||||
|
|| compact.contains("forced-colors:active")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width constraints, both `min-width:`/`max-width:` and the modern range
|
||||||
|
// forms `width>=Npx` / `(Npx<=width)`.
|
||||||
if let Some(px) = extract_px(&compact, "max-width:") {
|
if let Some(px) = extract_px(&compact, "max-width:") {
|
||||||
if VIEWPORT_W > px {
|
if VIEWPORT_W > px {
|
||||||
return false;
|
return false;
|
||||||
@@ -211,9 +497,232 @@ fn media_query_applies(query: &str) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(px) = extract_px(&compact, "width<=") {
|
||||||
|
if VIEWPORT_W > px {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(px) = extract_px(&compact, "width>=") {
|
||||||
|
if VIEWPORT_W < px {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(px) = extract_px(&compact, "width>") {
|
||||||
|
if VIEWPORT_W <= px {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(px) = extract_px(&compact, "width<") {
|
||||||
|
if VIEWPORT_W >= px {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Combine a nested selector `child` with its enclosing `parent` (CSS Nesting).
|
||||||
|
/// `&` in the child is replaced by the parent; a child with no `&` becomes a
|
||||||
|
/// descendant (`parent child`). Both may be comma lists, so the result is the
|
||||||
|
/// cartesian product. `parent` is None only at the stylesheet top level, where
|
||||||
|
/// the child is returned unchanged.
|
||||||
|
fn combine_selectors(parent: &str, child: &str) -> String {
|
||||||
|
let pparts = split_selector_list(parent);
|
||||||
|
let cparts = split_selector_list(child);
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for c in &cparts {
|
||||||
|
let c = c.trim();
|
||||||
|
if c.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for p in &pparts {
|
||||||
|
let p = p.trim();
|
||||||
|
if c.contains('&') {
|
||||||
|
out.push(c.replace('&', p));
|
||||||
|
} else {
|
||||||
|
out.push(format!("{} {}", p, c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten a rule body that may contain nested rules (CSS Nesting) into flat
|
||||||
|
/// `(selector, declarations)` pairs. The parser hands us the whole body of a
|
||||||
|
/// rule; here we separate its own declarations from nested `sel { ... }` blocks
|
||||||
|
/// (and nested `@media`/`@supports`/`@layer` at-rules, which keep the parent's
|
||||||
|
/// selector), emit `(sel, own-declarations)`, and recurse into each nested rule
|
||||||
|
/// with the combined selector. Without this, Tailwind v4 / modern-framework CSS
|
||||||
|
/// (which nests almost everything) loses the nested utility rules entirely.
|
||||||
|
fn denest(sel: &str, body: &str, rules: &mut Vec<(String, String)>) {
|
||||||
|
let chars: Vec<char> = body.chars().collect();
|
||||||
|
let n = chars.len();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut seg = 0; // start of the current declaration / nested prelude
|
||||||
|
let mut own = String::new();
|
||||||
|
let mut quote: Option<char> = None;
|
||||||
|
let mut comment = false;
|
||||||
|
let mut paren = 0i32;
|
||||||
|
while i < n {
|
||||||
|
let c = chars[i];
|
||||||
|
if comment {
|
||||||
|
if c == '*' && chars.get(i + 1) == Some(&'/') {
|
||||||
|
comment = false;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(q) = quote {
|
||||||
|
if c == q {
|
||||||
|
quote = None;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if c == '/' && chars.get(i + 1) == Some(&'*') {
|
||||||
|
comment = true;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match c {
|
||||||
|
'\'' | '"' => quote = Some(c),
|
||||||
|
'(' => paren += 1,
|
||||||
|
')' => paren = (paren - 1).max(0),
|
||||||
|
'{' if paren == 0 => {
|
||||||
|
let prelude: String = chars[seg..i].iter().collect();
|
||||||
|
// Find the matching close brace (quote/comment aware).
|
||||||
|
let mut depth = 1;
|
||||||
|
let mut j = i + 1;
|
||||||
|
let (mut q2, mut cm2) = (None::<char>, false);
|
||||||
|
while j < n && depth > 0 {
|
||||||
|
let cj = chars[j];
|
||||||
|
if cm2 {
|
||||||
|
if cj == '*' && chars.get(j + 1) == Some(&'/') {
|
||||||
|
cm2 = false;
|
||||||
|
j += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if let Some(qq) = q2 {
|
||||||
|
if cj == qq {
|
||||||
|
q2 = None;
|
||||||
|
}
|
||||||
|
} else if cj == '/' && chars.get(j + 1) == Some(&'*') {
|
||||||
|
cm2 = true;
|
||||||
|
j += 2;
|
||||||
|
continue;
|
||||||
|
} else if cj == '\'' || cj == '"' {
|
||||||
|
q2 = Some(cj);
|
||||||
|
} else if cj == '{' {
|
||||||
|
depth += 1;
|
||||||
|
} else if cj == '}' {
|
||||||
|
depth -= 1;
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let inner: String = chars[i + 1..j.saturating_sub(1).max(i + 1)].iter().collect();
|
||||||
|
let pre = prelude.trim();
|
||||||
|
if let Some(at) = pre.strip_prefix('@') {
|
||||||
|
// A nested at-rule keeps the enclosing selector for its body.
|
||||||
|
if at.starts_with("media") {
|
||||||
|
if media_query_applies(pre) {
|
||||||
|
denest(sel, &inner, rules);
|
||||||
|
}
|
||||||
|
} else if at.starts_with("supports") || at.starts_with("layer") {
|
||||||
|
denest(sel, &inner, rules);
|
||||||
|
}
|
||||||
|
} else if !pre.is_empty() {
|
||||||
|
let full = combine_selectors(sel, pre);
|
||||||
|
denest(&full, &inner, rules);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
seg = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
';' if paren == 0 => {
|
||||||
|
let d: String = chars[seg..i].iter().collect();
|
||||||
|
let d = d.trim();
|
||||||
|
if !d.is_empty() {
|
||||||
|
own.push_str(d);
|
||||||
|
own.push(';');
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
seg = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let tail: String = chars[seg..].iter().collect();
|
||||||
|
let tail = tail.trim();
|
||||||
|
if !tail.is_empty() && !tail.contains('{') {
|
||||||
|
own.push_str(tail);
|
||||||
|
own.push(';');
|
||||||
|
}
|
||||||
|
if !own.trim().is_empty() {
|
||||||
|
for s in split_selector_list(sel) {
|
||||||
|
let s = s.trim();
|
||||||
|
if !s.is_empty() {
|
||||||
|
rules.push((s.to_string(), own.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a CSS selector list at top-level commas only, leaving commas inside
|
||||||
|
/// `()` (e.g. `:is(a, b)`, `:not(.x, .y)`) and `[]` (`[attr="a,b"]`) and quoted
|
||||||
|
/// strings intact. A naive `split(',')` shatters those grouped selectors into
|
||||||
|
/// fragments that fail to compile, dropping the whole rule.
|
||||||
|
fn split_selector_list(sel: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut depth_paren = 0i32;
|
||||||
|
let mut depth_brack = 0i32;
|
||||||
|
let mut quote: Option<char> = None;
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut chars = sel.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'\\' => {
|
||||||
|
cur.push(c);
|
||||||
|
if let Some(n) = chars.next() {
|
||||||
|
cur.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if quote == Some(c) => {
|
||||||
|
quote = None;
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
'"' | '\'' if quote.is_none() => {
|
||||||
|
quote = Some(c);
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
'(' if quote.is_none() => {
|
||||||
|
depth_paren += 1;
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
')' if quote.is_none() => {
|
||||||
|
depth_paren -= 1;
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
'[' if quote.is_none() => {
|
||||||
|
depth_brack += 1;
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
']' if quote.is_none() => {
|
||||||
|
depth_brack -= 1;
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
',' if quote.is_none() && depth_paren == 0 && depth_brack == 0 => {
|
||||||
|
out.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
_ => cur.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(cur);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the number immediately following `prop` in `s`. Callers pass a
|
/// Read the number immediately following `prop` in `s`. Callers pass a
|
||||||
/// whitespace-stripped `s`, so the first non-digit character always ends the
|
/// whitespace-stripped `s`, so the first non-digit character always ends the
|
||||||
/// number. Handles a bare px value (`750px`) and the extremely common
|
/// number. Handles a bare px value (`750px`) and the extremely common
|
||||||
|
|||||||
+1171
-46
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,532 @@
|
|||||||
|
//! Real inline text layout via cosmic-text (HarfBuzz-class shaping through
|
||||||
|
//! rustybuzz + UAX#14 line breaking), replacing the earlier approximation
|
||||||
|
//! that split each text node into one taffy flex item per word.
|
||||||
|
//!
|
||||||
|
//! The model matches how real browsers separate formatting contexts: taffy
|
||||||
|
//! lays out the *block/flex/grid* boxes, and an *inline formatting context*
|
||||||
|
//! (a box whose children are all inline-level text/spans) collapses to a
|
||||||
|
//! single taffy leaf whose measure function line-breaks its shaped text at
|
||||||
|
//! whatever width taffy offers. Line wrapping, alignment, and intrinsic
|
||||||
|
//! sizing then come from a real text engine instead of flexbox tricks.
|
||||||
|
//!
|
||||||
|
//! Fonts are loaded from embedded bytes only, never the OS, so layout is
|
||||||
|
//! byte-for-byte deterministic across hosts (the whole engine's guarantee).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use cosmic_text::{Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap};
|
||||||
|
|
||||||
|
use obscura_dom::tree::{DomTree, NodeId};
|
||||||
|
|
||||||
|
use crate::{Display, LayoutStyle, Rect, TextTransform};
|
||||||
|
|
||||||
|
static REGULAR: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
|
||||||
|
static BOLD: &[u8] = include_bytes!("../assets/dejavu-sans-bold.ttf");
|
||||||
|
static OBLIQUE: &[u8] = include_bytes!("../assets/dejavu-sans-oblique.ttf");
|
||||||
|
static BOLD_OBLIQUE: &[u8] = include_bytes!("../assets/dejavu-sans-boldoblique.ttf");
|
||||||
|
|
||||||
|
const FAMILY: &str = "DejaVu Sans";
|
||||||
|
/// `line-height: normal` as a multiple of font-size. Browsers derive this
|
||||||
|
/// from font metrics (~1.15-1.2 for DejaVu Sans); 1.2 matches closely.
|
||||||
|
const NORMAL_LINE_HEIGHT: f32 = 1.2;
|
||||||
|
/// Underline is flagged per glyph through cosmic-text's `metadata` field.
|
||||||
|
const META_UNDERLINE: usize = 1;
|
||||||
|
|
||||||
|
/// One inline formatting context: a shaped cosmic-text buffer plus where to
|
||||||
|
/// paint it (filled in after layout).
|
||||||
|
pub struct InlineItem {
|
||||||
|
buffer: Buffer,
|
||||||
|
/// Content-box top-left in viewport coordinates, set by `finalize`.
|
||||||
|
origin: (f32, f32),
|
||||||
|
/// Ancestor `overflow: hidden` clip, set by `finalize`.
|
||||||
|
clip: Option<Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the font set and shaping caches for one render pass, plus every
|
||||||
|
/// inline formatting context discovered while building the tree. Lives in
|
||||||
|
/// [`crate::DomLayout`] so paint can rasterize the shaped glyphs.
|
||||||
|
pub struct TextEngine {
|
||||||
|
font_system: FontSystem,
|
||||||
|
swash: SwashCache,
|
||||||
|
items: Vec<InlineItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TextEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// Build a database from our four embedded faces only. Never call
|
||||||
|
// load_system_fonts: a host's font set would make layout differ
|
||||||
|
// machine to machine and add a multi-millisecond startup scan.
|
||||||
|
let mut db = cosmic_text::fontdb::Database::new();
|
||||||
|
for bytes in [REGULAR, BOLD, OBLIQUE, BOLD_OBLIQUE] {
|
||||||
|
db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes)));
|
||||||
|
}
|
||||||
|
db.set_sans_serif_family(FAMILY);
|
||||||
|
let font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
|
||||||
|
TextEngine { font_system, swash: SwashCache::new(), items: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of inline formatting contexts collected (for debug/stats).
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.items.len()
|
||||||
|
}
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.items.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an inline formatting context for `id`'s subtree if it is one we
|
||||||
|
/// can collapse to shaped text (see [`is_pure_text_ifc`]); returns the
|
||||||
|
/// item index to store as the leaf's taffy context. `None` means the
|
||||||
|
/// container is not a pure-text IFC and should build through the normal
|
||||||
|
/// (block / flex / word-split) path.
|
||||||
|
pub fn try_build(&mut self, tree: &DomTree, id: NodeId, styles: &std::collections::HashMap<NodeId, LayoutStyle>) -> Option<usize> {
|
||||||
|
if !is_pure_text_ifc(tree, id, styles) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = styles.get(&id)?;
|
||||||
|
let base_size = base.font_size.unwrap_or(16.0);
|
||||||
|
let default_color = base.color.unwrap_or([0, 0, 0, 255]);
|
||||||
|
|
||||||
|
let root_transform = base.text_transform.unwrap_or(TextTransform::None);
|
||||||
|
let root_underline = base.underline.unwrap_or(false);
|
||||||
|
let root_italic = base.font_style_italic.unwrap_or(false);
|
||||||
|
let root_bold = base.font_weight.as_deref() == Some("bold");
|
||||||
|
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||||
|
let mut collector = Collector { last_was_space: true };
|
||||||
|
let ctx = SpanCtx { color: default_color, bold: root_bold, italic: root_italic, underline: root_underline, transform: root_transform };
|
||||||
|
collect_spans(tree, id, styles, ctx, &mut spans, &mut collector);
|
||||||
|
// Trim a single trailing space so it does not widen the last line.
|
||||||
|
if let Some((text, _)) = spans.last_mut() {
|
||||||
|
if text.ends_with(' ') {
|
||||||
|
text.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spans.iter().all(|(t, _)| t.trim().is_empty()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real `line-height` drives vertical rhythm; a fixed ratio made
|
||||||
|
// real-site prose (e.g. Wikipedia's 1.6) noticeably tighter than
|
||||||
|
// Chromium. `normal` is font-relative; ~1.2 matches DejaVu closely.
|
||||||
|
let line_h = match base.line_height {
|
||||||
|
Some(crate::LineHeight::Px(px)) => px,
|
||||||
|
Some(crate::LineHeight::Ratio(r)) => base_size * r,
|
||||||
|
_ => base_size * NORMAL_LINE_HEIGHT,
|
||||||
|
};
|
||||||
|
// cosmic-text asserts (an uncatchable process abort) if font size OR
|
||||||
|
// line height is 0. `font-size:0` is a common whitespace-collapse trick
|
||||||
|
// and drives both to 0, so floor both at 1px here. The glyphs stay
|
||||||
|
// ~invisible, matching the intent, and one page can never abort a worker.
|
||||||
|
let cosmic_size = base_size.max(1.0);
|
||||||
|
let metrics = Metrics::new(cosmic_size, line_h.max(cosmic_size).ceil());
|
||||||
|
let mut buffer = Buffer::new(&mut self.font_system, metrics);
|
||||||
|
// Break only at word boundaries, never mid-word. This keeps the
|
||||||
|
// min-content width (measured at width 0) equal to the longest word,
|
||||||
|
// so a table/flex column never squeezes narrower than a whole word
|
||||||
|
// and forces an ugly mid-word break; a single word wider than its
|
||||||
|
// column overflows instead, matching normal `overflow-wrap: normal`.
|
||||||
|
buffer.set_wrap(&mut self.font_system, Wrap::Word);
|
||||||
|
// Always Advanced shaping: Basic mis-maps per-span attribute
|
||||||
|
// boundaries in cosmic-text 0.12 (a multi-color run like body text
|
||||||
|
// with links ends up coloring the wrong glyphs), and shaping is a
|
||||||
|
// small fraction of a page's CSS-cascade-dominated render time.
|
||||||
|
let rich = spans.iter().map(|(t, a)| (t.as_str(), a.to_attrs()));
|
||||||
|
buffer.set_rich_text(&mut self.font_system, rich, Attrs::new().family(Family::Name(FAMILY)), Shaping::Advanced);
|
||||||
|
|
||||||
|
let align = match base.align_items {
|
||||||
|
Some(taffy::AlignItems::CENTER) => Some(Align::Center),
|
||||||
|
Some(taffy::AlignItems::FLEX_END) => Some(Align::End),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(a) = align {
|
||||||
|
for line in buffer.lines.iter_mut() {
|
||||||
|
line.set_align(Some(a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = self.items.len();
|
||||||
|
self.items.push(InlineItem { buffer, origin: (0.0, 0.0), clip: None });
|
||||||
|
Some(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure the inline context `idx` at `width` (content-box width, or
|
||||||
|
/// None for max-content), returning its shaped (width, height). Called by
|
||||||
|
/// taffy's measure function during layout.
|
||||||
|
pub fn measure(&mut self, idx: usize, width: Option<f32>) -> (f32, f32) {
|
||||||
|
let TextEngine { font_system, items, .. } = self;
|
||||||
|
let item = &mut items[idx];
|
||||||
|
item.buffer.set_size(font_system, width.map(|w| w.max(0.0)), None);
|
||||||
|
item.buffer.shape_until_scroll(font_system, false);
|
||||||
|
buffer_size(&item.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After layout, pin each context to its final content-box origin and
|
||||||
|
/// clip, reshaping once at the resolved width so paint draws the same
|
||||||
|
/// line breaks the box was sized for.
|
||||||
|
pub fn finalize(&mut self, idx: usize, content_origin: (f32, f32), content_width: f32, clip: Option<Rect>) {
|
||||||
|
let TextEngine { font_system, items, .. } = self;
|
||||||
|
let item = &mut items[idx];
|
||||||
|
item.buffer.set_size(font_system, Some(content_width.max(0.0)), None);
|
||||||
|
item.buffer.shape_until_scroll(font_system, false);
|
||||||
|
item.origin = content_origin;
|
||||||
|
item.clip = clip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run of same-styled inline text.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
struct SpanAttrs {
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
color: [u8; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpanAttrs {
|
||||||
|
fn to_attrs(&self) -> Attrs<'_> {
|
||||||
|
let mut a = Attrs::new().family(Family::Name(FAMILY));
|
||||||
|
a = a.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL });
|
||||||
|
a = a.style(if self.italic { Style::Italic } else { Style::Normal });
|
||||||
|
a = a.color(Color::rgba(self.color[0], self.color[1], self.color[2], self.color[3]));
|
||||||
|
// Underline has no native cosmic-text attribute; carry it through the
|
||||||
|
// per-glyph metadata so paint can stroke it (see `paint_item`).
|
||||||
|
a = a.metadata(if self.underline { META_UNDERLINE } else { 0 });
|
||||||
|
a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inherited inline context threaded down the subtree while collecting spans.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct SpanCtx {
|
||||||
|
color: [u8; 4],
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
transform: TextTransform,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Collector {
|
||||||
|
last_was_space: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DFS the inline subtree, appending whitespace-collapsed text runs. Adjacent
|
||||||
|
/// runs with identical attributes are merged so cosmic-text sees the fewest
|
||||||
|
/// spans. Collapsing spans HTML's insignificant whitespace (runs of spaces,
|
||||||
|
/// tabs, and newlines fold to one space; leading space at the start of the
|
||||||
|
/// context is dropped) exactly as `white-space: normal` requires.
|
||||||
|
fn collect_spans(
|
||||||
|
tree: &DomTree,
|
||||||
|
id: NodeId,
|
||||||
|
styles: &std::collections::HashMap<NodeId, LayoutStyle>,
|
||||||
|
ctx: SpanCtx,
|
||||||
|
out: &mut Vec<(String, SpanAttrs)>,
|
||||||
|
c: &mut Collector,
|
||||||
|
) {
|
||||||
|
for cid in tree.children(id) {
|
||||||
|
let Some(node) = tree.get_node(cid) else { continue };
|
||||||
|
match &node.data {
|
||||||
|
obscura_dom::tree::NodeData::Text { contents } => {
|
||||||
|
let attrs = SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color };
|
||||||
|
push_text(contents, ctx.transform, &attrs, out, c);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let Some(elem) = node.as_element() else { continue };
|
||||||
|
let style = styles.get(&cid);
|
||||||
|
if style.map(|s| s.display == Display::None).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if elem.local.as_ref() == "br" {
|
||||||
|
out.push(("\n".to_string(), SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color }));
|
||||||
|
c.last_was_space = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let child = SpanCtx {
|
||||||
|
color: style.and_then(|s| s.color).unwrap_or(ctx.color),
|
||||||
|
bold: ctx.bold || style.map(|s| s.font_weight.as_deref() == Some("bold")).unwrap_or(false),
|
||||||
|
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
|
||||||
|
// Underline propagates in: an ancestor's underline covers
|
||||||
|
// descendant text; an element only sets its own via CSS.
|
||||||
|
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
|
||||||
|
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
|
||||||
|
};
|
||||||
|
collect_spans(tree, cid, styles, child, out, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_text(raw: &str, transform: TextTransform, attrs: &SpanAttrs, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector) {
|
||||||
|
let mut buf = String::new();
|
||||||
|
let mut at_word_start = c.last_was_space;
|
||||||
|
for ch in raw.chars() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
if !c.last_was_space {
|
||||||
|
buf.push(' ');
|
||||||
|
c.last_was_space = true;
|
||||||
|
}
|
||||||
|
at_word_start = true;
|
||||||
|
} else {
|
||||||
|
match transform {
|
||||||
|
TextTransform::Uppercase => buf.extend(ch.to_uppercase()),
|
||||||
|
TextTransform::Lowercase => buf.extend(ch.to_lowercase()),
|
||||||
|
TextTransform::Capitalize if at_word_start => buf.extend(ch.to_uppercase()),
|
||||||
|
_ => buf.push(ch),
|
||||||
|
}
|
||||||
|
c.last_was_space = false;
|
||||||
|
at_word_start = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buf.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((last_text, last_attrs)) = out.last_mut() {
|
||||||
|
if last_attrs == attrs {
|
||||||
|
last_text.push_str(&buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push((buf, attrs.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total shaped size of a buffer: widest line, and the bottom of the last line.
|
||||||
|
fn buffer_size(buffer: &Buffer) -> (f32, f32) {
|
||||||
|
let mut w = 0.0f32;
|
||||||
|
let mut h = 0.0f32;
|
||||||
|
for run in buffer.layout_runs() {
|
||||||
|
w = w.max(run.line_w);
|
||||||
|
h = h.max(run.line_top + run.line_height);
|
||||||
|
}
|
||||||
|
(w.ceil(), h.ceil())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does `id` establish an inline formatting context made purely of text and
|
||||||
|
/// plain inline formatting (no atomic inline boxes, no block children, no
|
||||||
|
/// inline element carrying its own box)? Such a container collapses cleanly
|
||||||
|
/// to one shaped buffer; anything else keeps the general build path.
|
||||||
|
fn is_pure_text_ifc(tree: &DomTree, id: NodeId, styles: &std::collections::HashMap<NodeId, LayoutStyle>) -> bool {
|
||||||
|
let Some(style) = styles.get(&id) else { return false };
|
||||||
|
// Only containers that lay their children out in normal flow (block, or
|
||||||
|
// the flex-column stand-ins our UA sheet uses for td/th/center). A real
|
||||||
|
// flex/grid row with inline children is rare and better left to taffy.
|
||||||
|
let flow = style.display == Display::Block
|
||||||
|
|| (style.display == Display::Flex && style.flex_direction == Some(taffy::FlexDirection::Column));
|
||||||
|
if !flow {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut has_text = false;
|
||||||
|
let children = tree.children(id);
|
||||||
|
if children.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for cid in &children {
|
||||||
|
if !inline_child_ok(tree, *cid, styles, &mut has_text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
has_text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaced / atomic-inline tags: their box does not contain text, so folding
|
||||||
|
/// one into a shaped buffer would drop its content entirely. A subtree that
|
||||||
|
/// contains one is never a pure-text IFC (it keeps the general build path,
|
||||||
|
/// where the element gets a real taffy box). This is by tag, not display, so a
|
||||||
|
/// stylesheet setting `img{display:inline}` cannot trick us into folding it.
|
||||||
|
fn is_replaced(local: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
local,
|
||||||
|
"img" | "svg" | "canvas" | "video" | "audio" | "iframe" | "embed"
|
||||||
|
| "object" | "input" | "textarea" | "select" | "button" | "progress"
|
||||||
|
| "meter"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `cid` (and its whole subtree) inline-level, in-flow content safe to fold
|
||||||
|
/// into a shaped buffer? Sets `has_text` if it contributes any non-whitespace
|
||||||
|
/// text. Inline wrappers (`<a>`, `<span>`, `<b>`, `<code>`, `<sup>`, ...) are
|
||||||
|
/// accepted and recursed into even when they carry a background or border of
|
||||||
|
/// their own: keeping the whole paragraph as one shaped run (correct wrapping
|
||||||
|
/// plus per-span color/weight/style/underline that `collect_spans` threads) is
|
||||||
|
/// worth losing an inline decoration, and it avoids the taffy-discouraged
|
||||||
|
/// flex word-promotion path that breaks real prose wrapping. Only boxes that
|
||||||
|
/// genuinely cannot fold are rejected: replaced/atomic elements, block-level
|
||||||
|
/// children, floats, out-of-flow positioned boxes, and elements with generated
|
||||||
|
/// content (which would be lost).
|
||||||
|
fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashMap<NodeId, LayoutStyle>, has_text: &mut bool) -> bool {
|
||||||
|
let Some(node) = tree.get_node(cid) else { return true };
|
||||||
|
match &node.data {
|
||||||
|
obscura_dom::tree::NodeData::Text { contents } => {
|
||||||
|
if !contents.trim().is_empty() {
|
||||||
|
*has_text = true;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
obscura_dom::tree::NodeData::Element { .. } => {
|
||||||
|
let Some(elem) = node.as_element() else { return true };
|
||||||
|
let Some(style) = styles.get(&cid) else { return false };
|
||||||
|
if style.display == Display::None {
|
||||||
|
return true; // removed from flow; ignore its subtree
|
||||||
|
}
|
||||||
|
if elem.local.as_ref() == "br" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A replaced element or an atomic inline-block has its own box with
|
||||||
|
// non-text content; it must stay a real taffy box (keep flex path).
|
||||||
|
if is_replaced(elem.local.as_ref()) || style.is_inline_block {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Only genuinely inline-level, in-flow boxes fold. A block-level
|
||||||
|
// child, a float, or an out-of-flow positioned box each needs the
|
||||||
|
// general path; so does an element with generated ::before/::after
|
||||||
|
// content (lost if folded) or an overflow clip of its own.
|
||||||
|
let foldable_inline = style.display == Display::Inline
|
||||||
|
&& style.position.is_none()
|
||||||
|
&& style.float.is_none()
|
||||||
|
&& !style.overflow_hidden
|
||||||
|
&& style.before_content.is_none()
|
||||||
|
&& style.after_content.is_none();
|
||||||
|
if !foldable_inline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for gc in tree.children(cid) {
|
||||||
|
if !inline_child_ok(tree, gc, styles, has_text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content-box origin for a container whose border box is `rect`, i.e. inside
|
||||||
|
/// its border and padding: where inline text actually starts.
|
||||||
|
pub fn content_origin(rect: &Rect, style: &LayoutStyle) -> (f32, f32) {
|
||||||
|
(rect.x + style.border.left + style.padding.left, rect.y + style.border.top + style.padding.top)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 {
|
||||||
|
(rect.width - style.border.left - style.border.right - style.padding.left - style.padding.right).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextEngine {
|
||||||
|
/// Rasterize inline context `idx` into `pixmap`, honoring its clip. Uses
|
||||||
|
/// cosmic-text's swash-backed rasterizer (anti-aliased, per-glyph color
|
||||||
|
/// from the span attributes).
|
||||||
|
pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap) {
|
||||||
|
let TextEngine { font_system, swash, items } = self;
|
||||||
|
let Some(item) = items.get_mut(idx) else { return };
|
||||||
|
let (ox, oy) = item.origin;
|
||||||
|
let clip = item.clip;
|
||||||
|
let pw = pixmap.width() as i32;
|
||||||
|
let ph = pixmap.height() as i32;
|
||||||
|
let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height));
|
||||||
|
|
||||||
|
// Collect underline segments before drawing glyphs (both borrow the
|
||||||
|
// buffer). Underline is carried per glyph via metadata; group runs of
|
||||||
|
// consecutive underlined glyphs on a line into one stroke below the
|
||||||
|
// baseline. Done first so the draw() mutable borrow does not overlap.
|
||||||
|
let mut underlines: Vec<(f32, f32, f32, f32, [u8; 4])> = Vec::new(); // x0, x1, y, thickness, color
|
||||||
|
for run in item.buffer.layout_runs() {
|
||||||
|
let base_y = run.line_y;
|
||||||
|
let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color
|
||||||
|
for g in run.glyphs {
|
||||||
|
let underlined = g.metadata == META_UNDERLINE as usize;
|
||||||
|
let col = g.color_opt.map(|c| [c.r(), c.g(), c.b(), c.a()]).unwrap_or([0, 0, 0, 255]);
|
||||||
|
if underlined {
|
||||||
|
match &mut seg {
|
||||||
|
Some((_, x1, fs, c)) if *c == col => {
|
||||||
|
*x1 = g.x + g.w;
|
||||||
|
*fs = fs.max(g.font_size);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some((x0, x1, fs, c)) = seg.take() {
|
||||||
|
underlines.push((x0, x1, base_y + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c));
|
||||||
|
}
|
||||||
|
seg = Some((g.x, g.x + g.w, g.font_size, col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((x0, x1, fs, c)) = seg.take() {
|
||||||
|
underlines.push((x0, x1, base_y + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((x0, x1, fs, c)) = seg.take() {
|
||||||
|
underlines.push((x0, x1, base_y + (fs * 0.12).max(1.0), (fs / 14.0).max(1.0), c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback color if a glyph carries none (shouldn't happen: every span
|
||||||
|
// sets one), black.
|
||||||
|
let default = Color::rgba(0, 0, 0, 255);
|
||||||
|
let pixels = pixmap.pixels_mut();
|
||||||
|
item.buffer.draw(font_system, swash, default, |gx, gy, gw, gh, color| {
|
||||||
|
let a = color.a() as u32;
|
||||||
|
if a == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (r, g, b) = (color.r(), color.g(), color.b());
|
||||||
|
for dy in 0..gh as i32 {
|
||||||
|
for dx in 0..gw as i32 {
|
||||||
|
let px = ox as i32 + gx + dx;
|
||||||
|
let py = oy as i32 + gy + dy;
|
||||||
|
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 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if px < 0 || px >= pw || py < 0 || py >= ph {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let idx = (py * pw + px) as usize;
|
||||||
|
let dst = pixels[idx];
|
||||||
|
let sa = a;
|
||||||
|
let sr = (r as u32 * sa) / 255;
|
||||||
|
let sg = (g as u32 * sa) / 255;
|
||||||
|
let sb = (b as u32 * sa) / 255;
|
||||||
|
let inv = 255 - sa;
|
||||||
|
let out_a = sa + (dst.alpha() as u32 * inv / 255);
|
||||||
|
if out_a == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let out_r = sr + (dst.red() as u32 * inv / 255);
|
||||||
|
let out_g = sg + (dst.green() as u32 * inv / 255);
|
||||||
|
let out_b = sb + (dst.blue() as u32 * inv / 255);
|
||||||
|
pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(out_r as u8, out_g as u8, out_b as u8, out_a as u8)
|
||||||
|
.unwrap_or(dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stroke the underline segments (opaque; text is already drawn above).
|
||||||
|
for (x0, x1, y, thick, col) in underlines {
|
||||||
|
let t = thick.max(1.0).round() as i32;
|
||||||
|
for dt in 0..t {
|
||||||
|
let py = oy as i32 + y as i32 + dt;
|
||||||
|
if py < 0 || py >= ph {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((_, cy0, _, cy1)) = clip_bounds {
|
||||||
|
if (py as f32) < cy0 || (py as f32) >= cy1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for px in (ox + x0) as i32..(ox + x1) as i32 {
|
||||||
|
if px < 0 || px >= pw {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((cx0, _, cx1, _)) = clip_bounds {
|
||||||
|
if (px as f32) < cx0 || (px as f32) >= cx1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let i = (py * pw + px) as usize;
|
||||||
|
pixels[i] = tiny_skia::PremultipliedColorU8::from_rgba(col[0], col[1], col[2], 255).unwrap_or(pixels[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,40 @@ pub mod style;
|
|||||||
pub use style::compute_style;
|
pub use style::compute_style;
|
||||||
|
|
||||||
pub mod dom;
|
pub mod dom;
|
||||||
pub use dom::{layout_dom, DomLayout};
|
pub use dom::{layout_dom, layout_dom_with_images, DomLayout};
|
||||||
|
|
||||||
#[cfg(feature = "paint")]
|
#[cfg(feature = "paint")]
|
||||||
mod paint;
|
mod paint;
|
||||||
#[cfg(feature = "paint")]
|
#[cfg(feature = "paint")]
|
||||||
pub use paint::{paint_dom, screenshot_png};
|
pub use paint::{paint_dom, screenshot_png};
|
||||||
|
|
||||||
|
// Real inline text layout (cosmic-text) lives behind the paint feature; the
|
||||||
|
// layout-only build keeps the lighter word-split geometry. The stub lets
|
||||||
|
// `dom.rs` name `inline::TextEngine` and call `try_build` unconditionally.
|
||||||
|
#[cfg(feature = "paint")]
|
||||||
|
pub mod inline;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "paint"))]
|
||||||
|
pub mod inline {
|
||||||
|
use obscura_dom::tree::{DomTree, NodeId};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TextEngine;
|
||||||
|
|
||||||
|
impl TextEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
TextEngine
|
||||||
|
}
|
||||||
|
/// Layout-only builds have no shaper, so no container is ever treated
|
||||||
|
/// as a cosmic-text inline formatting context: the word-split path
|
||||||
|
/// handles text geometry for `getBoundingClientRect`.
|
||||||
|
pub fn try_build(&mut self, _tree: &DomTree, _id: NodeId, _styles: &HashMap<NodeId, crate::LayoutStyle>) -> Option<usize> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An axis-aligned rectangle in CSS pixels, relative to the containing block.
|
/// An axis-aligned rectangle in CSS pixels, relative to the containing block.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||||
pub struct Rect {
|
pub struct Rect {
|
||||||
@@ -51,6 +78,16 @@ impl Rect {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The smallest rect covering both. Used to derive a table row/section box
|
||||||
|
/// from its cells, since `<tr>`/`<tbody>` are not laid out as taffy boxes.
|
||||||
|
pub fn union(&self, other: &Rect) -> Rect {
|
||||||
|
let x0 = self.x.min(other.x);
|
||||||
|
let y0 = self.y.min(other.y);
|
||||||
|
let x1 = (self.x + self.width).max(other.x + other.width);
|
||||||
|
let y1 = (self.y + self.height).max(other.y + other.height);
|
||||||
|
Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-edge box values (margin / padding / border) in CSS pixels.
|
/// Per-edge box values (margin / padding / border) in CSS pixels.
|
||||||
@@ -80,7 +117,37 @@ pub enum Dimension {
|
|||||||
#[default]
|
#[default]
|
||||||
Auto,
|
Auto,
|
||||||
Px(f32),
|
Px(f32),
|
||||||
|
/// 0.0-1.0 fraction of the containing block.
|
||||||
Percent(f32),
|
Percent(f32),
|
||||||
|
/// Font-relative and viewport-relative units, kept unresolved at parse
|
||||||
|
/// time (the element font-size and viewport are not known then) and
|
||||||
|
/// resolved to `Px` during `dom::layout_dom`'s top-down pass via
|
||||||
|
/// [`Dimension::resolve`]. Resolving em/rem/vw against a hardcoded 16px at
|
||||||
|
/// parse time (the old behavior) silently corrupted every relative length.
|
||||||
|
Em(f32),
|
||||||
|
Rem(f32),
|
||||||
|
Vw(f32),
|
||||||
|
Vh(f32),
|
||||||
|
Vmin(f32),
|
||||||
|
Vmax(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dimension {
|
||||||
|
/// Resolve font/viewport-relative units to `Px`. `em_px` is the element's
|
||||||
|
/// own font-size, `rem_px` the root's, and `vw`/`vh` are one hundredth of
|
||||||
|
/// the viewport width/height. `Px`, `Percent`, and `Auto` pass through
|
||||||
|
/// (`Percent` stays for taffy to resolve against the containing block).
|
||||||
|
pub fn resolve(self, em_px: f32, rem_px: f32, vw: f32, vh: f32) -> Dimension {
|
||||||
|
match self {
|
||||||
|
Dimension::Em(v) => Dimension::Px(v * em_px),
|
||||||
|
Dimension::Rem(v) => Dimension::Px(v * rem_px),
|
||||||
|
Dimension::Vw(v) => Dimension::Px(v * vw),
|
||||||
|
Dimension::Vh(v) => Dimension::Px(v * vh),
|
||||||
|
Dimension::Vmin(v) => Dimension::Px(v * vw.min(vh)),
|
||||||
|
Dimension::Vmax(v) => Dimension::Px(v * vw.max(vh)),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The subset of CSS that influences box layout. Expanded in later phases.
|
/// The subset of CSS that influences box layout. Expanded in later phases.
|
||||||
@@ -89,15 +156,38 @@ pub struct LayoutStyle {
|
|||||||
pub display: Display,
|
pub display: Display,
|
||||||
pub width: Dimension,
|
pub width: Dimension,
|
||||||
pub height: Dimension,
|
pub height: Dimension,
|
||||||
|
/// Whether `width`/`height` was set by an author rule (including an explicit
|
||||||
|
/// `auto`). Presentational `width`/`height` HTML attributes are a lower
|
||||||
|
/// priority than author CSS, so they apply only when these are false; an
|
||||||
|
/// explicit `width:auto` must still suppress a `width="408"` attribute so
|
||||||
|
/// the element keeps its aspect-ratio size instead of the intrinsic one.
|
||||||
|
pub width_set: bool,
|
||||||
|
pub height_set: bool,
|
||||||
pub min_width: Dimension,
|
pub min_width: Dimension,
|
||||||
pub min_height: Dimension,
|
pub min_height: Dimension,
|
||||||
pub max_width: Dimension,
|
pub max_width: Dimension,
|
||||||
pub max_height: Dimension,
|
pub max_height: Dimension,
|
||||||
|
/// `aspect-ratio` as width/height, or an image's intrinsic ratio resolved
|
||||||
|
/// at layout. Lets a replaced element (or a padding-box card) derive the
|
||||||
|
/// missing dimension from the given one, so a `width:100%` image gets a
|
||||||
|
/// real height instead of collapsing to zero.
|
||||||
|
pub aspect_ratio: Option<f32>,
|
||||||
pub margin: Edges,
|
pub margin: Edges,
|
||||||
|
/// Which margin sides are `auto` (top, right, bottom, left). `margin: 0
|
||||||
|
/// auto` / `margin-inline: auto` centering needs a real Auto margin, which
|
||||||
|
/// the f32 `margin` cannot express; this flag drives it at taffy mapping.
|
||||||
|
pub margin_auto: [bool; 4],
|
||||||
pub padding: Edges,
|
pub padding: Edges,
|
||||||
pub border: Edges,
|
pub border: Edges,
|
||||||
|
/// `border-radius` (uniform; the first value of the shorthand). Rounds the
|
||||||
|
/// background fill and border. In px after resolution.
|
||||||
|
pub border_radius: f32,
|
||||||
/// 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]>,
|
||||||
|
/// `linear-gradient(...)` background: (angle in degrees clockwise from 12
|
||||||
|
/// o'clock per CSS, list of (rgba, optional 0..1 stop position)). Modern
|
||||||
|
/// hero sections use gradients heavily; without this they paint white.
|
||||||
|
pub background_gradient: Option<(f32, Vec<([u8; 4], Option<f32>)>)>,
|
||||||
/// The first `url(...)` reference from `background`/`background-image`
|
/// The first `url(...)` reference from `background`/`background-image`
|
||||||
/// (gradients and repeat keywords in the same shorthand are ignored: we
|
/// (gradients and repeat keywords in the same shorthand are ignored: we
|
||||||
/// paint the referenced image, not the gradient layer).
|
/// paint the referenced image, not the gradient layer).
|
||||||
@@ -122,6 +212,10 @@ pub struct LayoutStyle {
|
|||||||
pub color: Option<[u8; 4]>,
|
pub color: Option<[u8; 4]>,
|
||||||
pub border_color: Option<[u8; 4]>,
|
pub border_color: Option<[u8; 4]>,
|
||||||
pub font_size: Option<f32>,
|
pub font_size: Option<f32>,
|
||||||
|
/// `font-size` given in a font/viewport-relative unit, resolved to
|
||||||
|
/// `font_size` (px) during the inheritance pass against the parent and
|
||||||
|
/// root font-sizes. `None` when font-size was absolute or unset.
|
||||||
|
pub font_size_raw: Option<Dimension>,
|
||||||
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>,
|
||||||
@@ -129,6 +223,10 @@ pub struct LayoutStyle {
|
|||||||
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>,
|
pub flex_shrink: Option<f32>,
|
||||||
|
/// `flex-basis` (longhand, or the length in a `flex:` shorthand). `Auto`
|
||||||
|
/// is the default. Fixed-basis sidebars/columns (`flex: 0 0 260px`)
|
||||||
|
/// collapse to content width without it.
|
||||||
|
pub flex_basis: Dimension,
|
||||||
|
|
||||||
// CSS Grid. Tracks are stored as taffy sizing functions; `grid_areas` is the
|
// 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),
|
// parsed `grid-template-areas` matrix (one Vec per row, `.` for a null cell),
|
||||||
@@ -139,12 +237,31 @@ pub struct LayoutStyle {
|
|||||||
pub grid_area_name: Option<String>,
|
pub grid_area_name: Option<String>,
|
||||||
pub grid_column: Option<taffy::Line<taffy::GridPlacement>>,
|
pub grid_column: Option<taffy::Line<taffy::GridPlacement>>,
|
||||||
pub grid_row: Option<taffy::Line<taffy::GridPlacement>>,
|
pub grid_row: Option<taffy::Line<taffy::GridPlacement>>,
|
||||||
|
/// `[line-name]` -> 1-based grid line number, parsed from
|
||||||
|
/// `grid-template-columns`/`-rows`. taffy has no native named-line support,
|
||||||
|
/// so children placed by name (`grid-column: content-start / content-end`,
|
||||||
|
/// widely used by the Guardian and other editorial grids) are resolved to
|
||||||
|
/// numeric lines against these maps in `dom::resolve_grid_areas`.
|
||||||
|
pub grid_col_line_names: Option<std::collections::HashMap<String, i16>>,
|
||||||
|
pub grid_row_line_names: Option<std::collections::HashMap<String, i16>>,
|
||||||
|
/// Raw `grid-column`/`grid-row` value when it references a named line (so it
|
||||||
|
/// cannot be resolved to a `taffy::Line` until the parent's line-name map is
|
||||||
|
/// known). Resolved in the same later pass; numeric/`span` values still fill
|
||||||
|
/// `grid_column`/`grid_row` directly at cascade time.
|
||||||
|
pub grid_column_raw: Option<String>,
|
||||||
|
pub grid_row_raw: Option<String>,
|
||||||
pub column_gap: Option<f32>,
|
pub column_gap: Option<f32>,
|
||||||
pub row_gap: Option<f32>,
|
pub row_gap: Option<f32>,
|
||||||
|
|
||||||
|
/// `border-spacing: <horizontal> <vertical>?` (or the `cellspacing`
|
||||||
|
/// attribute). Only meaningful on a `<table>`; taffy has no native table
|
||||||
|
/// display mode, so `dom::propagate_border_spacing` distributes this down
|
||||||
|
/// as the table's own row gap and each descendant `<tr>`'s column gap.
|
||||||
|
pub border_spacing: Option<(f32, f32)>,
|
||||||
|
|
||||||
// Positioning. `position: absolute|fixed` takes the box out of normal flow.
|
// Positioning. `position: absolute|fixed` takes the box out of normal flow.
|
||||||
pub position: Option<taffy::Position>,
|
pub position: Option<taffy::Position>,
|
||||||
pub inset: [Option<f32>; 4], // top, right, bottom, left
|
pub inset: [Option<Dimension>; 4], // top, right, bottom, left
|
||||||
|
|
||||||
/// `overflow`/-x/-y other than `visible`: clips this element's descendants
|
/// `overflow`/-x/-y other than `visible`: clips this element's descendants
|
||||||
/// to its border box during paint. This is what makes the ubiquitous
|
/// to its border box during paint. This is what makes the ubiquitous
|
||||||
@@ -157,6 +274,77 @@ pub struct LayoutStyle {
|
|||||||
/// float's shape, which taffy's block/flex/grid modes do not do; see
|
/// float's shape, which taffy's block/flex/grid modes do not do; see
|
||||||
/// `dom::group_float_zone` for the bounded approximation this drives.
|
/// `dom::group_float_zone` for the bounded approximation this drives.
|
||||||
pub float: Option<Float>,
|
pub float: Option<Float>,
|
||||||
|
|
||||||
|
/// `visibility: hidden|visible`, own value. `None` means "inherit the
|
||||||
|
/// ancestor's computed value" (visibility, unlike most box properties, is
|
||||||
|
/// a real inherited CSS property). Resolved into `effectively_invisible`
|
||||||
|
/// during `dom::layout_dom`'s inheritance pass.
|
||||||
|
pub visibility_hidden: Option<bool>,
|
||||||
|
/// `opacity`, own (non-inherited) value in 0.0-1.0. `None` means the
|
||||||
|
/// default of 1.0.
|
||||||
|
pub opacity: Option<f32>,
|
||||||
|
/// Resolved during the inheritance pass: true when this element should
|
||||||
|
/// not be painted at all, either from its own or an inherited
|
||||||
|
/// `visibility: hidden`, or because the product of its own and every
|
||||||
|
/// ancestor's `opacity` has collapsed to (near) zero. Real `opacity`
|
||||||
|
/// composites as translucent groups, which our paint step does not do;
|
||||||
|
/// collapsing it to a binary paint/don't-paint decision is enough to
|
||||||
|
/// make the extremely common "opacity:0 + visibility:hidden" collapsed
|
||||||
|
/// dropdown/panel pattern actually invisible, without needing full alpha
|
||||||
|
/// compositing.
|
||||||
|
pub effectively_invisible: bool,
|
||||||
|
|
||||||
|
/// Literal text injected by a `::before`/`::after` rule with a plain
|
||||||
|
/// string-literal `content` (see `css::Stylesheet::pseudo_content`).
|
||||||
|
/// Rendered as an extra word-run at the start/end of this element's
|
||||||
|
/// children, same as if it were real text content.
|
||||||
|
pub before_content: Option<String>,
|
||||||
|
pub after_content: Option<String>,
|
||||||
|
|
||||||
|
/// True for `inline-block`/`inline-flex`/`inline-grid`: participates in
|
||||||
|
/// the surrounding inline flow from the outside, like plain `inline`
|
||||||
|
/// (both currently collapse to `Display::Inline` — this engine has no
|
||||||
|
/// separate inline-block layout mode), but unlike plain `inline` it must
|
||||||
|
/// stay a single atomic box rather than have its own content merge into
|
||||||
|
/// the parent's line-breaking. `dom::is_flattenable_inline` uses this to
|
||||||
|
/// avoid flattening these away: doing so would lose the element as its
|
||||||
|
/// own box (including any `::before`/`::after` content attached to it).
|
||||||
|
pub is_inline_block: bool,
|
||||||
|
|
||||||
|
/// `display: contents`: the element generates no box of its own; its
|
||||||
|
/// children participate in the parent's formatting context directly
|
||||||
|
/// (`dom::build_any` splices them into the parent's child list). Kept as a
|
||||||
|
/// flag beside `display` because the element still carries inherited styles
|
||||||
|
/// for its subtree and `display:none` must still win.
|
||||||
|
pub display_contents: bool,
|
||||||
|
|
||||||
|
/// `list-style-type` (or the `list-style` shorthand). Inherited, like in
|
||||||
|
/// real CSS; `None` means "not set on this element, inherit". Resolved to
|
||||||
|
/// a concrete value during the inheritance pass. Only `<li>` elements draw
|
||||||
|
/// a marker from it, but it is carried on every element because it
|
||||||
|
/// inherits (a `list-style: none` on a `<ul>` must reach its `<li>`
|
||||||
|
/// children, which is how nav menus suppress bullets).
|
||||||
|
pub list_style: Option<ListStyle>,
|
||||||
|
|
||||||
|
/// `line-height`. Inherited. `None` means "not set, inherit"; resolved to
|
||||||
|
/// a concrete value in the inheritance pass. Drives the vertical rhythm of
|
||||||
|
/// shaped text (a fixed ratio made real-site prose noticeably tighter than
|
||||||
|
/// Chromium).
|
||||||
|
pub line_height: Option<LineHeight>,
|
||||||
|
|
||||||
|
/// `text-transform`. Inherited. Applied to span text before shaping.
|
||||||
|
pub text_transform: Option<TextTransform>,
|
||||||
|
|
||||||
|
/// `text-decoration-line: underline` (or the `text-decoration` shorthand).
|
||||||
|
/// Not inherited in CSS, but a decoration visually covers descendant inline
|
||||||
|
/// text, so it is propagated into the shaped spans of the element's subtree
|
||||||
|
/// (this is what underlines links, which are underlined by UA default).
|
||||||
|
pub underline: Option<bool>,
|
||||||
|
|
||||||
|
/// `font-style: italic|oblique`. Inherited. Selects the oblique face when
|
||||||
|
/// shaping (we embed the DejaVu Sans oblique/bold-oblique faces). `None`
|
||||||
|
/// means inherit.
|
||||||
|
pub font_style_italic: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -165,6 +353,34 @@ pub enum Float {
|
|||||||
Right,
|
Right,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `line-height`: `normal` (a font-relative default), a unitless multiple of
|
||||||
|
/// font-size, or an absolute pixel length.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum LineHeight {
|
||||||
|
Normal,
|
||||||
|
Ratio(f32),
|
||||||
|
Px(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TextTransform {
|
||||||
|
None,
|
||||||
|
Uppercase,
|
||||||
|
Lowercase,
|
||||||
|
Capitalize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `list-style-type` values we render a marker for. `Decimal` numbers the
|
||||||
|
/// item by its position among sibling list items.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ListStyle {
|
||||||
|
None,
|
||||||
|
Disc,
|
||||||
|
Circle,
|
||||||
|
Square,
|
||||||
|
Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
/// does not affect layout in phase 1 (inline/text layout comes later).
|
/// does not affect layout in phase 1 (inline/text layout comes later).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -245,7 +461,7 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
|||||||
// column, matching the same column-flex approximation already used for
|
// column, matching the same column-flex approximation already used for
|
||||||
// elements like <td> and <center>.
|
// elements like <td> and <center>.
|
||||||
let promote_for_alignment = style.display == Display::Block
|
let promote_for_alignment = style.display == Display::Block
|
||||||
&& matches!(style.align_items, Some(taffy::AlignItems::Center) | Some(taffy::AlignItems::FlexEnd));
|
&& matches!(style.align_items, Some(taffy::AlignItems::CENTER) | Some(taffy::AlignItems::FLEX_END));
|
||||||
|
|
||||||
s.display = match style.display {
|
s.display = match style.display {
|
||||||
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
|
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
|
||||||
@@ -273,6 +489,16 @@ 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),
|
||||||
};
|
};
|
||||||
|
// Tell taffy's own layout algorithm about `overflow: hidden`, not just our
|
||||||
|
// paint-time clip rects: per spec, a flex/grid item's automatic minimum
|
||||||
|
// size is content-based only when its overflow is `visible`, and `0`
|
||||||
|
// otherwise. Without this, an explicit `height: 0; overflow: hidden`
|
||||||
|
// element (the common collapsed-dropdown-panel pattern: hidden until a
|
||||||
|
// sibling checkbox is checked) still grows to fit its content, since
|
||||||
|
// taffy has no way to know it should not.
|
||||||
|
if style.overflow_hidden {
|
||||||
|
s.overflow = taffy::Point { x: taffy::style::Overflow::Hidden, y: taffy::style::Overflow::Hidden };
|
||||||
|
}
|
||||||
s.min_size = taffy::Size {
|
s.min_size = taffy::Size {
|
||||||
width: dimension(style.min_width),
|
width: dimension(style.min_width),
|
||||||
height: dimension(style.min_height),
|
height: dimension(style.min_height),
|
||||||
@@ -281,6 +507,11 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
|||||||
width: dimension(style.max_width),
|
width: dimension(style.max_width),
|
||||||
height: dimension(style.max_height),
|
height: dimension(style.max_height),
|
||||||
};
|
};
|
||||||
|
if let Some(ar) = style.aspect_ratio {
|
||||||
|
if ar.is_finite() && ar > 0.0 {
|
||||||
|
s.aspect_ratio = Some(ar);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(ai) = style.align_items {
|
if let Some(ai) = style.align_items {
|
||||||
s.align_items = Some(ai);
|
s.align_items = Some(ai);
|
||||||
}
|
}
|
||||||
@@ -293,29 +524,50 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
|||||||
if let Some(fs) = style.flex_shrink {
|
if let Some(fs) = style.flex_shrink {
|
||||||
s.flex_shrink = fs;
|
s.flex_shrink = fs;
|
||||||
}
|
}
|
||||||
|
if style.flex_basis != Dimension::Auto {
|
||||||
|
s.flex_basis = dimension(style.flex_basis);
|
||||||
|
}
|
||||||
|
|
||||||
// Grid container tracks and gaps.
|
// Grid container tracks and gaps. obscura expands `repeat()` itself during
|
||||||
|
// parsing, so every stored track is a single (non-repeated) `MinMax`; wrap
|
||||||
|
// each as a `GridTemplateComponent::Single` for taffy 0.12. The 0.7-era
|
||||||
|
// fr->Auto row workaround (which stopped `minmax(0,1fr)` image rows from
|
||||||
|
// collapsing to a sliver) is gone: taffy 0.12 treats an in-flow child's
|
||||||
|
// vertical available space as indefinite, so fr rows of an auto-height grid
|
||||||
|
// size to their content the way real CSS does.
|
||||||
if style.display == Display::Grid {
|
if style.display == Display::Grid {
|
||||||
if !style.grid_template_columns.is_empty() {
|
if !style.grid_template_columns.is_empty() {
|
||||||
s.grid_template_columns = style.grid_template_columns.clone();
|
s.grid_template_columns = style
|
||||||
|
.grid_template_columns
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(taffy::GridTemplateComponent::Single)
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
if !style.grid_template_rows.is_empty() {
|
if !style.grid_template_rows.is_empty() {
|
||||||
s.grid_template_rows = style.grid_template_rows.clone();
|
s.grid_template_rows = style
|
||||||
|
.grid_template_rows
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(taffy::GridTemplateComponent::Single)
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let cg = style.column_gap.unwrap_or(0.0);
|
let cg = style.column_gap.unwrap_or(0.0);
|
||||||
let rg = style.row_gap.unwrap_or(0.0);
|
let rg = style.row_gap.unwrap_or(0.0);
|
||||||
s.gap = taffy::Size {
|
s.gap = taffy::Size {
|
||||||
width: taffy::style::LengthPercentage::Length(cg),
|
width: taffy::style::LengthPercentage::length(cg),
|
||||||
height: taffy::style::LengthPercentage::Length(rg),
|
height: taffy::style::LengthPercentage::length(rg),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Grid item placement (resolved from grid-area names or explicit lines).
|
// Grid item placement (resolved from grid-area names or explicit lines).
|
||||||
if let Some(gc) = style.grid_column {
|
// `GridPlacement` is no longer `Copy` in taffy 0.12 (it can carry a named
|
||||||
s.grid_column = gc;
|
// line), so clone out of the borrowed style.
|
||||||
|
if let Some(gc) = &style.grid_column {
|
||||||
|
s.grid_column = gc.clone();
|
||||||
}
|
}
|
||||||
if let Some(gr) = style.grid_row {
|
if let Some(gr) = &style.grid_row {
|
||||||
s.grid_row = gr;
|
s.grid_row = gr.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Positioning. Absolute/fixed take the box out of flow.
|
// Positioning. Absolute/fixed take the box out of flow.
|
||||||
@@ -329,42 +581,58 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
s.margin = rect_auto(style.margin);
|
s.margin = rect_auto(style.margin, style.margin_auto);
|
||||||
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 {
|
fn inset_lpa(v: Option<Dimension>) -> taffy::style::LengthPercentageAuto {
|
||||||
match v {
|
match v {
|
||||||
Some(px) => taffy::style::LengthPercentageAuto::Length(px),
|
Some(Dimension::Px(px)) => taffy::style::LengthPercentageAuto::length(px),
|
||||||
None => taffy::style::LengthPercentageAuto::Auto,
|
Some(Dimension::Percent(p)) => taffy::style::LengthPercentageAuto::percent(p),
|
||||||
|
// Relative units are resolved to Px before layout; unresolved leftovers
|
||||||
|
// and `auto`/absent both map to Auto.
|
||||||
|
_ => 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),
|
||||||
Dimension::Percent(p) => taffy::style::Dimension::Percent(p),
|
Dimension::Percent(p) => taffy::style::Dimension::percent(p),
|
||||||
Dimension::Auto => taffy::style::Dimension::Auto,
|
Dimension::Auto => taffy::style::Dimension::auto(),
|
||||||
|
// Relative units are resolved to Px before layout; if one slips
|
||||||
|
// through unresolved, fall back to its raw magnitude (em/rem ~16px)
|
||||||
|
// rather than panicking.
|
||||||
|
Dimension::Em(v) | Dimension::Rem(v) => taffy::style::Dimension::length(v * 16.0),
|
||||||
|
Dimension::Vw(v) | Dimension::Vh(v) | Dimension::Vmin(v) | Dimension::Vmax(v) => taffy::style::Dimension::length(v),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rect_lp(e: Edges) -> taffy::Rect<taffy::style::LengthPercentage> {
|
fn rect_lp(e: Edges) -> taffy::Rect<taffy::style::LengthPercentage> {
|
||||||
taffy::Rect {
|
taffy::Rect {
|
||||||
top: taffy::style::LengthPercentage::Length(e.top),
|
top: taffy::style::LengthPercentage::length(e.top),
|
||||||
right: taffy::style::LengthPercentage::Length(e.right),
|
right: taffy::style::LengthPercentage::length(e.right),
|
||||||
bottom: taffy::style::LengthPercentage::Length(e.bottom),
|
bottom: taffy::style::LengthPercentage::length(e.bottom),
|
||||||
left: taffy::style::LengthPercentage::Length(e.left),
|
left: taffy::style::LengthPercentage::length(e.left),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rect_auto(e: Edges) -> taffy::Rect<taffy::style::LengthPercentageAuto> {
|
fn rect_auto(e: Edges, auto: [bool; 4]) -> taffy::Rect<taffy::style::LengthPercentageAuto> {
|
||||||
|
// NOTE: `margin: auto` is parsed (see `margin_auto`) but intentionally not
|
||||||
|
// emitted as taffy `Auto` yet. In our architecture most text containers are
|
||||||
|
// promoted to synthetic flex rows for inline layout, and an auto margin in
|
||||||
|
// a flex row absorbs the row's free space and flings a wide child far to
|
||||||
|
// one side (it mangled Wikipedia taxobox timelines). Honoring auto-margin
|
||||||
|
// centering safely needs a real block formatting context; treat as 0 (the
|
||||||
|
// element's normal-flow position) until then.
|
||||||
|
let _ = auto;
|
||||||
taffy::Rect {
|
taffy::Rect {
|
||||||
top: taffy::style::LengthPercentageAuto::Length(e.top),
|
top: taffy::style::LengthPercentageAuto::length(e.top),
|
||||||
right: taffy::style::LengthPercentageAuto::Length(e.right),
|
right: taffy::style::LengthPercentageAuto::length(e.right),
|
||||||
bottom: taffy::style::LengthPercentageAuto::Length(e.bottom),
|
bottom: taffy::style::LengthPercentageAuto::length(e.bottom),
|
||||||
left: taffy::style::LengthPercentageAuto::Length(e.left),
|
left: taffy::style::LengthPercentageAuto::length(e.left),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
//! dependencies, so a screenshot is reproducible across hosts.
|
//! dependencies, so a screenshot is reproducible across hosts.
|
||||||
|
|
||||||
use obscura_dom::tree::DomTree;
|
use obscura_dom::tree::DomTree;
|
||||||
use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Rect, Transform};
|
use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, Rect, SpreadMode, Transform};
|
||||||
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
|
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
|
||||||
|
|
||||||
static FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
|
static FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
|
||||||
|
|
||||||
use crate::layout_dom;
|
use crate::layout_dom_with_images;
|
||||||
|
|
||||||
/// 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. `base_url`, when given, resolves the
|
/// None if the viewport is zero-sized. `base_url`, when given, resolves the
|
||||||
@@ -23,12 +23,17 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
|||||||
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);
|
|
||||||
// The same URL (an icon sprite, a repeated background image) commonly
|
// The same URL (an icon sprite, a repeated background image) commonly
|
||||||
// backs many elements on one page; fetch each distinct URL at most once
|
// 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
|
// per screenshot. `None` caches a failed fetch too, so a broken image
|
||||||
// reference does not retry on every element that references it.
|
// 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();
|
let mut image_cache: std::collections::HashMap<String, Option<Vec<u8>>> = std::collections::HashMap::new();
|
||||||
|
// Fetch <img> bytes up front to learn intrinsic sizes for layout (a
|
||||||
|
// CSS-sized image with no width/height attribute would otherwise be 0x0
|
||||||
|
// and never paint). This seeds the same cache the paint pass reads, so
|
||||||
|
// each URL is still fetched at most once.
|
||||||
|
let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache);
|
||||||
|
let mut laid = layout_dom_with_images(tree, viewport, &intrinsic);
|
||||||
// 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) {
|
||||||
@@ -55,6 +60,10 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
|||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if style.effectively_invisible {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Ancestor `overflow: hidden` clip, if any. Skip painting entirely
|
// Ancestor `overflow: hidden` clip, if any. Skip painting entirely
|
||||||
// once the box has no visible overlap with it (this is what makes the
|
// once the box has no visible overlap with it (this is what makes the
|
||||||
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
|
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
|
||||||
@@ -72,19 +81,34 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
|||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A masked element's background-color is the mask's fill color, not
|
// Box path (rounded if border-radius), reused for gradient/color fill.
|
||||||
// an ordinary box background: it must only show through the mask
|
let r = style.border_radius;
|
||||||
// shape (painted below), not as a solid rect behind it.
|
let bg_path = || if r > 0.5 {
|
||||||
if let (Some(bg), None) = (style.background_color, &style.mask_image) {
|
rounded_rect_path(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height, r)
|
||||||
let mut path = PathBuilder::new();
|
} else {
|
||||||
path.push_rect(box_rect);
|
let mut pb = PathBuilder::new();
|
||||||
if let Some(path) = path.finish() {
|
pb.push_rect(box_rect);
|
||||||
|
pb.finish()
|
||||||
|
};
|
||||||
|
// A linear-gradient background (heavily used by modern hero sections);
|
||||||
|
// without this it paints white. Takes precedence over a solid color.
|
||||||
|
if style.mask_image.is_none() {
|
||||||
|
if let Some((angle, stops)) = &style.background_gradient {
|
||||||
|
if let Some(path) = bg_path() {
|
||||||
|
paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops);
|
||||||
|
}
|
||||||
|
} else if let Some(bg) = style.background_color {
|
||||||
|
// A masked element's background-color is the mask's fill color,
|
||||||
|
// not an ordinary box background (handled below), so this only
|
||||||
|
// runs for unmasked boxes.
|
||||||
|
if let Some(path) = bg_path() {
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color(Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
paint.set_color(Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||||
paint.anti_alias = false;
|
paint.anti_alias = r > 0.5;
|
||||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(mask_url) = &style.mask_image {
|
if let Some(mask_url) = &style.mask_image {
|
||||||
let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]);
|
let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]);
|
||||||
@@ -116,7 +140,23 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if style.border.top > 0.0 || style.border.right > 0.0 || style.border.bottom > 0.0 || style.border.left > 0.0 {
|
// Rounded, uniform border: stroke the rounded-rect outline instead of
|
||||||
|
// four sharp edge rects.
|
||||||
|
let uniform_border = style.border.top == style.border.right
|
||||||
|
&& style.border.right == style.border.bottom
|
||||||
|
&& style.border.bottom == style.border.left
|
||||||
|
&& style.border.top > 0.0;
|
||||||
|
if style.border_radius > 0.5 && uniform_border {
|
||||||
|
let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]);
|
||||||
|
let w = style.border.top;
|
||||||
|
if let Some(path) = rounded_rect_path(rect.x + w / 2.0, rect.y + w / 2.0, rect.width - w, rect.height - w, (style.border_radius - w / 2.0).max(0.0)) {
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3]));
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let stroke = tiny_skia::Stroke { width: w, ..Default::default() };
|
||||||
|
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
} else if style.border.top > 0.0 || style.border.right > 0.0 || style.border.bottom > 0.0 || style.border.left > 0.0 {
|
||||||
let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]);
|
let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]);
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3]));
|
paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3]));
|
||||||
@@ -150,10 +190,20 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
if name.local.as_ref() == "img" {
|
if name.local.as_ref() == "img" {
|
||||||
if let Some(src) = node.get_attribute("src") {
|
if let Some(src) = resolve_img_url(tree, nid) {
|
||||||
let painted = paint_image(src, base_url, &rect, &mut pixmap, &mut image_cache);
|
let painted = paint_image(&src, base_url, &rect, &mut pixmap, &mut image_cache);
|
||||||
// Fall back to alt text only when the image itself did not paint.
|
// Fall back to alt text only when the image itself did not paint.
|
||||||
if !painted {
|
if !painted {
|
||||||
|
// A fetch/decode failure paints a neutral grey box (like a
|
||||||
|
// browser's lazy/broken-image placeholder) so a missing
|
||||||
|
// image reads as "not loaded", not as a broken render.
|
||||||
|
// box_rect/visible_rect are already clip-intersected, so
|
||||||
|
// this never paints outside an overflow:hidden clip.
|
||||||
|
if visible_rect.width >= 4.0 && visible_rect.height >= 4.0 {
|
||||||
|
let mut ph = Paint::default();
|
||||||
|
ph.set_color(Color::from_rgba8(0xE9, 0xEA, 0xEC, 0xFF));
|
||||||
|
pixmap.fill_rect(box_rect, &ph, Transform::identity(), None);
|
||||||
|
}
|
||||||
if let Some(alt) = node.get_attribute("alt") {
|
if let Some(alt) = node.get_attribute("alt") {
|
||||||
if !alt.is_empty() {
|
if !alt.is_empty() {
|
||||||
draw_text(&mut pixmap, alt, rect.x, rect.y, [0, 0, 0, 255], 12.0, false, clip);
|
draw_text(&mut pixmap, alt, rect.x, rect.y, [0, 0, 0, 255], 12.0, false, clip);
|
||||||
@@ -162,10 +212,114 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List-item marker (bullet or number), drawn in the indent to the left
|
||||||
|
// of the item's content box. `list_style` is inherited and resolved,
|
||||||
|
// so `None` (e.g. a nav `<ul style="list-style:none">`) suppresses it.
|
||||||
|
if name.local.as_ref() == "li" {
|
||||||
|
if let Some(marker) = list_marker_text(tree, nid, style.list_style) {
|
||||||
|
let fsize = style.font_size.unwrap_or(16.0);
|
||||||
|
let color = style.color.unwrap_or([0, 0, 0, 255]);
|
||||||
|
let mw = measure_text(&marker, fsize, false);
|
||||||
|
let mx = rect.x + style.padding.left - mw - 6.0;
|
||||||
|
let my = rect.y + style.border.top + style.padding.top;
|
||||||
|
draw_text(&mut pixmap, &marker, mx, my, color, fsize, false, clip);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `::before`/`::after` generated text (see `dom::build_pseudo_content`)
|
||||||
|
// has no DOM text node of its own; its word runs are registered under
|
||||||
|
// the host element's own id instead, so paint them here rather than
|
||||||
|
// through `paint_text_node` (which only runs for real text nodes).
|
||||||
|
if let Some(runs) = laid.text_runs.get(&nid) {
|
||||||
|
let color = style.color.unwrap_or([0, 0, 0, 255]);
|
||||||
|
let fsize = style.font_size.unwrap_or(16.0);
|
||||||
|
let is_bold = style.font_weight.as_deref() == Some("bold");
|
||||||
|
for (word_rect, word) in runs {
|
||||||
|
draw_text(&mut pixmap, word, word_rect.x, word_rect.y, color, fsize, is_bold, clip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty text `<input>`/`<textarea>` shows its `placeholder`
|
||||||
|
// attribute as muted text; there is no DOM text node for it (it is
|
||||||
|
// not real content), so paint it directly from the attribute instead
|
||||||
|
// of going through `paint_text_node`.
|
||||||
|
if name.local.as_ref() == "input" || name.local.as_ref() == "textarea" {
|
||||||
|
let has_value = node.get_attribute("value").map(|v| !v.is_empty()).unwrap_or(false);
|
||||||
|
if !has_value {
|
||||||
|
if let Some(placeholder) = node.get_attribute("placeholder") {
|
||||||
|
if !placeholder.is_empty() {
|
||||||
|
let fsize = style.font_size.unwrap_or(16.0);
|
||||||
|
let text_x = rect.x + style.padding.left + style.border.left;
|
||||||
|
let text_y = rect.y + style.padding.top + style.border.top;
|
||||||
|
draw_text(&mut pixmap, placeholder, text_x, text_y, [117, 117, 117, 255], fsize, false, clip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline formatting contexts shaped by cosmic-text (paragraphs, headings,
|
||||||
|
// cells, labels) draw last, in tree order, so their glyphs sit above the
|
||||||
|
// box backgrounds/borders painted in the loop above. Each item already
|
||||||
|
// carries its final origin and clip from `TextEngine::finalize`.
|
||||||
|
for nid in tree.descendants(tree.document()) {
|
||||||
|
if let Some(&idx) = laid.ifc_items.get(&nid) {
|
||||||
|
if laid.styles.get(&nid).map(|s| s.effectively_invisible).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
laid.text_engine.paint_item(idx, &mut pixmap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some(pixmap)
|
Some(pixmap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A closed rounded-rectangle path, corners approximated by quadratic curves
|
||||||
|
/// (visually indistinguishable from true arcs at typical UI radii). `r` is
|
||||||
|
/// clamped so it never exceeds half the shorter side.
|
||||||
|
fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option<tiny_skia::Path> {
|
||||||
|
if w <= 0.0 || h <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let r = r.min(w / 2.0).min(h / 2.0).max(0.0);
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.move_to(x + r, y);
|
||||||
|
pb.line_to(x + w - r, y);
|
||||||
|
pb.quad_to(x + w, y, x + w, y + r);
|
||||||
|
pb.line_to(x + w, y + h - r);
|
||||||
|
pb.quad_to(x + w, y + h, x + w - r, y + h);
|
||||||
|
pb.line_to(x + r, y + h);
|
||||||
|
pb.quad_to(x, y + h, x, y + h - r);
|
||||||
|
pb.line_to(x, y + r);
|
||||||
|
pb.quad_to(x, y, x + r, y);
|
||||||
|
pb.close();
|
||||||
|
pb.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marker text for a list item, or `None` when markers are suppressed
|
||||||
|
/// (`list-style: none`). `Decimal` numbers the item by its position among
|
||||||
|
/// sibling list items so `<ol>`s count 1, 2, 3.
|
||||||
|
fn list_marker_text(tree: &DomTree, nid: obscura_dom::tree::NodeId, style: Option<crate::ListStyle>) -> Option<String> {
|
||||||
|
match style {
|
||||||
|
Some(crate::ListStyle::Disc) => Some("\u{2022}".to_string()),
|
||||||
|
Some(crate::ListStyle::Circle) => Some("\u{25E6}".to_string()),
|
||||||
|
Some(crate::ListStyle::Square) => Some("\u{25AA}".to_string()),
|
||||||
|
Some(crate::ListStyle::Decimal) => {
|
||||||
|
let mut n = 1usize;
|
||||||
|
let mut cur = tree.get_node(nid).and_then(|node| node.prev_sibling);
|
||||||
|
while let Some(sib) = cur {
|
||||||
|
if tree.get_node(sib).and_then(|s| s.as_element().map(|e| e.local.to_string())).as_deref() == Some("li") {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
cur = tree.get_node(sib).and_then(|s| s.prev_sibling);
|
||||||
|
}
|
||||||
|
Some(format!("{}.", n))
|
||||||
|
}
|
||||||
|
Some(crate::ListStyle::None) | None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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), base_url: Option<&str>) -> Option<Vec<u8>> {
|
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Vec<u8>> {
|
||||||
@@ -187,6 +341,9 @@ fn paint_text_node(
|
|||||||
let node = tree.get_node(nid)?;
|
let node = tree.get_node(nid)?;
|
||||||
let parent = node.parent?;
|
let parent = node.parent?;
|
||||||
let style = laid.styles.get(&parent)?;
|
let style = laid.styles.get(&parent)?;
|
||||||
|
if style.effectively_invisible {
|
||||||
|
return Some(());
|
||||||
|
}
|
||||||
let color = style.color.unwrap_or([0, 0, 0, 255]);
|
let color = style.color.unwrap_or([0, 0, 0, 255]);
|
||||||
let fsize = style.font_size.unwrap_or(16.0);
|
let fsize = style.font_size.unwrap_or(16.0);
|
||||||
let is_bold = style.font_weight.as_deref() == Some("bold");
|
let is_bold = style.font_weight.as_deref() == Some("bold");
|
||||||
@@ -331,15 +488,68 @@ fn fetch_bytes(
|
|||||||
let url = resolved?;
|
let url = resolved?;
|
||||||
cache
|
cache
|
||||||
.entry(url.clone())
|
.entry(url.clone())
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| http_get_bytes(&url))
|
||||||
ureq::get(&url).call().ok().and_then(|resp| {
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch `url` with a descriptive User-Agent and a bounded timeout, retrying on
|
||||||
|
/// rate-limit / transient errors with backoff. Real pages pull dozens of images
|
||||||
|
/// from one CDN in a burst (a Wikipedia article references ~60); hosts like
|
||||||
|
/// Wikimedia answer a rapid burst with HTTP 429 after ~10 requests. Without a
|
||||||
|
/// retry the rate-limited images (e.g. an infobox photo montage fetched late in
|
||||||
|
/// the burst) came back blank, and the failure was cached permanently. The
|
||||||
|
/// backoff both recovers them and paces the burst back under the limit.
|
||||||
|
fn http_get_bytes(url: &str) -> Option<Vec<u8>> {
|
||||||
|
let mut backoff = std::time::Duration::from_millis(200);
|
||||||
|
for attempt in 0..3 {
|
||||||
|
// A browser-like Accept advertises the modern image formats and is what
|
||||||
|
// content-negotiating CDNs expect; some UA-gated hosts also reject a
|
||||||
|
// request with no Accept header outright.
|
||||||
|
let res = image_agent()
|
||||||
|
.get(url)
|
||||||
|
.set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||||
|
.call();
|
||||||
|
match res {
|
||||||
|
Ok(resp) => {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
resp.into_reader().read_to_end(&mut buf).ok()?;
|
return resp.into_reader().read_to_end(&mut buf).ok().map(|_| buf);
|
||||||
Some(buf)
|
}
|
||||||
|
// 429 (rate limit) and 5xx are transient: back off and retry.
|
||||||
|
Err(ureq::Error::Status(code, _)) if matches!(code, 429 | 500 | 502 | 503 | 504) && attempt < 2 => {
|
||||||
|
std::thread::sleep(backoff);
|
||||||
|
backoff *= 2;
|
||||||
|
}
|
||||||
|
// A network/transport error is also worth one more try.
|
||||||
|
Err(ureq::Error::Transport(_)) if attempt < 2 => {
|
||||||
|
std::thread::sleep(backoff);
|
||||||
|
backoff *= 2;
|
||||||
|
}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One shared HTTP agent for all image fetches in the process, with a browser
|
||||||
|
/// User-Agent and keep-alive connection pooling. A CDN's bot rate-limiter keys
|
||||||
|
/// on connection churn as much as on rate: a fresh TLS handshake per image (the
|
||||||
|
/// old per-call `ureq::get`) reads as a burst and gets 429'd, whereas reusing
|
||||||
|
/// one pooled connection to the same host (as a browser does) both avoids most
|
||||||
|
/// throttling and is much faster on an image-heavy page.
|
||||||
|
fn image_agent() -> &'static ureq::Agent {
|
||||||
|
static AGENT: std::sync::OnceLock<ureq::Agent> = std::sync::OnceLock::new();
|
||||||
|
AGENT.get_or_init(|| {
|
||||||
|
ureq::AgentBuilder::new()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
// Present the same normal browser identity the engine uses for the
|
||||||
|
// document. A bot-identifying UA got image requests filtered by CDNs
|
||||||
|
// that gate on User-Agent (Akamai/Cloudflare image endpoints on
|
||||||
|
// cnbc, techcrunch, arstechnica), so the images Chrome loads came
|
||||||
|
// back blank; a real browser UA loads the same bytes Chrome does.
|
||||||
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
.build()
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode a percent-escaped data: URI payload (`%23` -> `#`, etc). Bytes that
|
/// Decode a percent-escaped data: URI payload (`%23` -> `#`, etc). Bytes that
|
||||||
@@ -386,6 +596,303 @@ fn raster_to_pixmap(bytes: &[u8], w: u32, h: u32) -> Option<Pixmap> {
|
|||||||
Pixmap::from_vec(raw, size)
|
Pixmap::from_vec(raw, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an image's intrinsic pixel dimensions from its header only, without
|
||||||
|
/// decoding the whole thing. Returns None for formats the raster decoder does
|
||||||
|
/// not recognize (e.g. SVG, which is sized elsewhere).
|
||||||
|
/// Fill `path` with a CSS `linear-gradient`. `angle` is degrees clockwise from
|
||||||
|
/// 12 o'clock (0 = to top). The gradient line length uses the CSS formula so
|
||||||
|
/// the stops land where a browser puts them. Positionless stops are spread
|
||||||
|
/// evenly; positions are clamped monotonic (tiny-skia requires ascending).
|
||||||
|
fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &crate::Rect, angle: f32, stops: &[([u8; 4], Option<f32>)]) {
|
||||||
|
if stops.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rad = angle.to_radians();
|
||||||
|
let dx = rad.sin();
|
||||||
|
let dy = -rad.cos();
|
||||||
|
let (cx, cy) = (rect.x + rect.width / 2.0, rect.y + rect.height / 2.0);
|
||||||
|
let half = (dx.abs() * rect.width + dy.abs() * rect.height) / 2.0;
|
||||||
|
let start = Point::from_xy(cx - dx * half, cy - dy * half);
|
||||||
|
let end = Point::from_xy(cx + dx * half, cy + dy * half);
|
||||||
|
let n = stops.len();
|
||||||
|
let mut gs: Vec<GradientStop> = Vec::with_capacity(n);
|
||||||
|
let mut last = 0.0f32;
|
||||||
|
for (i, (c, pos)) in stops.iter().enumerate() {
|
||||||
|
let p = pos.unwrap_or(i as f32 / (n - 1) as f32).clamp(0.0, 1.0).max(last);
|
||||||
|
last = p;
|
||||||
|
gs.push(GradientStop::new(p, Color::from_rgba8(c[0], c[1], c[2], c[3])));
|
||||||
|
}
|
||||||
|
if let Some(shader) = LinearGradient::new(start, end, gs, SpreadMode::Pad, Transform::identity()) {
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.shader = shader;
|
||||||
|
paint.anti_alias = true;
|
||||||
|
pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||||
|
image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?
|
||||||
|
.into_dimensions()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch every `<img>` once (seeding `cache` for the paint pass) and record its
|
||||||
|
/// intrinsic (width, height) so layout can size replaced elements that have no
|
||||||
|
/// explicit dimensions. Keyed by the `<img>`'s NodeId.
|
||||||
|
fn collect_image_intrinsics(
|
||||||
|
tree: &DomTree,
|
||||||
|
base_url: Option<&str>,
|
||||||
|
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||||
|
) -> std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)> {
|
||||||
|
let mut out = std::collections::HashMap::new();
|
||||||
|
for nid in tree.descendants(tree.document()) {
|
||||||
|
let Some(node) = tree.get_node(nid) else { continue };
|
||||||
|
if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(url) = resolve_img_url(tree, nid) else { continue };
|
||||||
|
let Some(bytes) = fetch_bytes(&url, base_url, cache) else { continue };
|
||||||
|
if let Some((w, h)) = image_dimensions(&bytes) {
|
||||||
|
if w > 0 && h > 0 {
|
||||||
|
out.insert(nid, (w as f32, h as f32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose the URL to paint for an `<img>`. Browsers do not use `src` alone:
|
||||||
|
/// a wrapping `<picture>`'s `<source>`s, `srcset`, and `sizes` select by
|
||||||
|
/// type/media/viewport/density, and lazy-loaded images keep the real URL in
|
||||||
|
/// `data-src`/`data-srcset` with `src` holding a 1x1 placeholder until script
|
||||||
|
/// swaps it in. Since obscura may not have run the site's lazy-load script,
|
||||||
|
/// resolve the same URL the browser would end up with: a matching `<picture>`
|
||||||
|
/// source first, then a real candidate from `srcset`/`data-srcset`, then a
|
||||||
|
/// non-inline `src`/`data-*` URL, then any `src` (an inlined data: image).
|
||||||
|
fn resolve_img_url(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<String> {
|
||||||
|
let node = tree.get_node(nid)?;
|
||||||
|
// A <picture>'s preceding, type/media-matching <source> wins over the
|
||||||
|
// <img>'s own attributes (HTML "update the source set").
|
||||||
|
if let Some(url) = picture_source_url(tree, nid) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
let sizes = node.get_attribute("sizes");
|
||||||
|
for a in ["srcset", "data-srcset"] {
|
||||||
|
if let Some(v) = node.get_attribute(a) {
|
||||||
|
if let Some(u) = best_srcset_candidate(v, sizes) {
|
||||||
|
return Some(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let url_attrs = ["src", "data-src", "data-lazy-src", "data-original", "data-fallback-src", "data-lazy"];
|
||||||
|
// A non-inline URL first (a data: src is usually the lazy-load placeholder).
|
||||||
|
for a in url_attrs {
|
||||||
|
if let Some(v) = node.get_attribute(a) {
|
||||||
|
let v = v.trim();
|
||||||
|
if !v.is_empty() && !v.starts_with("data:") {
|
||||||
|
return Some(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise fall back to whatever is there (an inlined data: image).
|
||||||
|
for a in url_attrs {
|
||||||
|
if let Some(v) = node.get_attribute(a) {
|
||||||
|
let v = v.trim();
|
||||||
|
if !v.is_empty() {
|
||||||
|
return Some(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When `img_nid` is an `<img>` inside a `<picture>`, walk its preceding
|
||||||
|
/// `<source>` siblings in document order and return the selected URL of the
|
||||||
|
/// first supported one (matching `type` and `media`), per WebKit's
|
||||||
|
/// `HTMLImageElement::bestFitSourceFromPictureElement`. `None` means no source
|
||||||
|
/// applied and the caller should fall back to the `<img>`'s own attributes.
|
||||||
|
fn picture_source_url(tree: &DomTree, img_nid: obscura_dom::tree::NodeId) -> Option<String> {
|
||||||
|
let img = tree.get_node(img_nid)?;
|
||||||
|
let parent = img.parent?;
|
||||||
|
let is_picture = tree
|
||||||
|
.get_node(parent)
|
||||||
|
.and_then(|p| p.as_element().map(|e| e.local.as_ref() == "picture"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_picture {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for cid in tree.children(parent) {
|
||||||
|
// Only sources that precede the <img> contribute.
|
||||||
|
if cid == img_nid {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(child) = tree.get_node(cid) else { continue };
|
||||||
|
if child.as_element().map(|e| e.local.as_ref() != "source").unwrap_or(true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(srcset) = child.get_attribute("srcset") else { continue };
|
||||||
|
if srcset.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(t) = child.get_attribute("type") {
|
||||||
|
if !source_type_supported(t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(m) = child.get_attribute("media") {
|
||||||
|
if !m.trim().is_empty() && !crate::css::media_query_applies(m) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sizes = child.get_attribute("sizes");
|
||||||
|
if let Some(u) = best_srcset_candidate(srcset, sizes) {
|
||||||
|
return Some(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a `<source type=...>` names an image format this build can decode.
|
||||||
|
/// AVIF/JPEG-XL are intentionally excluded: the `image` crate cannot decode
|
||||||
|
/// them here, so a decodable `<img>` fallback must win over such a source.
|
||||||
|
fn source_type_supported(t: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
t.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
|
||||||
|
| "image/bmp" | "image/svg+xml" | "image/x-icon" | "image/vnd.microsoft.icon"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assumed layout viewport width (matches the desktop width the `@media`
|
||||||
|
/// cascade evaluates against in `css.rs`). Used to turn `w` descriptors and
|
||||||
|
/// `vw`/`%` source sizes into effective pixel densities.
|
||||||
|
const SRCSET_VIEWPORT_W: f32 = 1280.0;
|
||||||
|
|
||||||
|
/// Pick one URL from a `srcset` list, matching the WebKit/Blink selection:
|
||||||
|
/// normalize each `w` descriptor to an effective density (`w / source-size`,
|
||||||
|
/// with the source-size taken from `sizes` or falling back to the viewport
|
||||||
|
/// width), treat `x` descriptors as-is and a bare candidate as `1x`, then pick
|
||||||
|
/// the smallest density at least the device pixel ratio (1 at DPR 1), else the
|
||||||
|
/// largest available.
|
||||||
|
fn best_srcset_candidate(srcset: &str, sizes: Option<&str>) -> Option<String> {
|
||||||
|
const DPR: f32 = 1.0;
|
||||||
|
let source_size = source_size_px(sizes);
|
||||||
|
let mut cands: Vec<(f32, String)> = Vec::new();
|
||||||
|
// Parse candidates WHATWG-style: a URL is a run of non-whitespace (so a
|
||||||
|
// data: URI's internal commas stay part of it, unlike a naive split on
|
||||||
|
// ','), optionally followed by a descriptor up to the next comma.
|
||||||
|
let is_ws = |c: char| c.is_whitespace();
|
||||||
|
let mut rest = srcset.trim_start_matches(|c: char| is_ws(c) || c == ',');
|
||||||
|
while !rest.is_empty() {
|
||||||
|
let url_end = rest.find(is_ws).unwrap_or(rest.len());
|
||||||
|
let raw_url = &rest[..url_end];
|
||||||
|
rest = &rest[url_end..];
|
||||||
|
// Trailing commas on the URL mean the candidate had no descriptor.
|
||||||
|
let url = raw_url.trim_end_matches(',');
|
||||||
|
let no_desc = url.len() != raw_url.len();
|
||||||
|
rest = rest.trim_start_matches(is_ws);
|
||||||
|
let desc = if no_desc {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
let d_end = rest.find(',').unwrap_or(rest.len());
|
||||||
|
let d = rest[..d_end].trim();
|
||||||
|
rest = &rest[d_end..];
|
||||||
|
d
|
||||||
|
};
|
||||||
|
rest = rest.trim_start_matches(|c: char| c == ',' || is_ws(c));
|
||||||
|
if url.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let density = if desc.is_empty() {
|
||||||
|
1.0
|
||||||
|
} else if let Some(w) = desc.strip_suffix('w').and_then(|s| s.parse::<f32>().ok()) {
|
||||||
|
if source_size > 0.0 { w / source_size } else { continue }
|
||||||
|
} else if let Some(x) = desc.strip_suffix('x').and_then(|s| s.parse::<f32>().ok()) {
|
||||||
|
x
|
||||||
|
} else {
|
||||||
|
// An `h` (height) descriptor or malformed token: skip the candidate.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
cands.push((density, url.to_string()));
|
||||||
|
}
|
||||||
|
if cands.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let pick = cands
|
||||||
|
.iter()
|
||||||
|
.find(|(d, _)| *d >= DPR)
|
||||||
|
.map(|(_, u)| u.clone())
|
||||||
|
.unwrap_or_else(|| cands.last().unwrap().1.clone());
|
||||||
|
Some(pick)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approximate the CSS px size an image will be displayed at, from its `sizes`
|
||||||
|
/// attribute: the first entry whose media condition holds at our assumed
|
||||||
|
/// desktop viewport (a bare entry always holds), else the viewport width. Used
|
||||||
|
/// only to convert `w` descriptors to densities, so a coarse value is fine.
|
||||||
|
fn source_size_px(sizes: Option<&str>) -> f32 {
|
||||||
|
let Some(sizes) = sizes else { return SRCSET_VIEWPORT_W };
|
||||||
|
for entry in sizes.split(',') {
|
||||||
|
let entry = entry.trim();
|
||||||
|
if entry.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (cond, len) = split_size_entry(entry);
|
||||||
|
if let Some(cond) = cond {
|
||||||
|
if !crate::css::media_query_applies(&cond) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(px) = length_to_px(&len) {
|
||||||
|
return px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SRCSET_VIEWPORT_W
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split one `sizes` entry into its optional leading media condition and its
|
||||||
|
/// trailing `<length>`. Tokenizes on whitespace at paren depth 0 so a
|
||||||
|
/// `calc(...)` length or a parenthesized condition stays intact; the last
|
||||||
|
/// token is the length, anything before it is the condition.
|
||||||
|
fn split_size_entry(entry: &str) -> (Option<String>, String) {
|
||||||
|
let mut tokens: Vec<String> = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut depth = 0i32;
|
||||||
|
for c in entry.chars() {
|
||||||
|
match c {
|
||||||
|
'(' => { depth += 1; cur.push(c); }
|
||||||
|
')' => { depth -= 1; cur.push(c); }
|
||||||
|
c if c.is_whitespace() && depth == 0 => {
|
||||||
|
if !cur.is_empty() { tokens.push(std::mem::take(&mut cur)); }
|
||||||
|
}
|
||||||
|
c => cur.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cur.is_empty() {
|
||||||
|
tokens.push(cur);
|
||||||
|
}
|
||||||
|
let len = tokens.pop().unwrap_or_default();
|
||||||
|
let cond = if tokens.is_empty() { None } else { Some(tokens.join(" ")) };
|
||||||
|
(cond, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `sizes` length to px against the assumed viewport. `vw`/`%` scale
|
||||||
|
/// by the viewport width; `px` is literal; `em`/`rem` use the 16px root.
|
||||||
|
/// `calc()` and other forms return `None` (the caller tries the next entry).
|
||||||
|
fn length_to_px(len: &str) -> Option<f32> {
|
||||||
|
let t = len.trim().to_ascii_lowercase();
|
||||||
|
let num = |s: &str| s.trim().parse::<f32>().ok();
|
||||||
|
if let Some(v) = t.strip_suffix("vw").and_then(num) { return Some(v / 100.0 * SRCSET_VIEWPORT_W); }
|
||||||
|
if let Some(v) = t.strip_suffix('%').and_then(num) { return Some(v / 100.0 * SRCSET_VIEWPORT_W); }
|
||||||
|
if let Some(v) = t.strip_suffix("px").and_then(num) { return Some(v); }
|
||||||
|
if let Some(v) = t.strip_suffix("rem").and_then(num) { return Some(v * 16.0); }
|
||||||
|
if let Some(v) = t.strip_suffix("em").and_then(num) { return Some(v * 16.0); }
|
||||||
|
num(&t)
|
||||||
|
}
|
||||||
|
|
||||||
fn paint_image(
|
fn paint_image(
|
||||||
src: &str,
|
src: &str,
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
|
|||||||
+1006
-102
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@
|
|||||||
//! no per-site hardcoded selectors. This guards against reintroducing the
|
//! no per-site hardcoded selectors. This guards against reintroducing the
|
||||||
//! site-specific hacks that used to live in obscura-render for this exact markup.
|
//! 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_dom::tree_sink::parse_html;
|
||||||
use obscura_render::layout_dom;
|
use obscura_render::layout_dom;
|
||||||
|
|
||||||
@@ -34,28 +33,28 @@ const HN_HTML: &str = r##"
|
|||||||
</table>
|
</table>
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
/// The top-left corner of a text node's overall bounding box: a text node
|
/// Top-left of the tightest laid-out element box whose text contains
|
||||||
/// lays out as one taffy leaf per word (see `dom::build_text_words`), so its
|
/// `needle`. Text geometry is no longer a per-word list: a pure-text
|
||||||
/// geometry lives in `text_runs` as several (box, word) pairs rather than a
|
/// container collapses to a single cosmic-text inline formatting context
|
||||||
/// single rect. `needle` is matched against the node's whole original
|
/// (see `inline`), and even in the word-split path the wrapping `<a>`/`<span>`
|
||||||
/// content, not a single word.
|
/// are flattened into their block, so the smallest element rect enclosing the
|
||||||
|
/// text is the meaningful, mode-independent anchor. Picking the smallest-area
|
||||||
|
/// match skips the giant ancestor tables that also "contain" the text.
|
||||||
fn find_by_text(
|
fn find_by_text(
|
||||||
tree: &obscura_dom::tree::DomTree,
|
tree: &obscura_dom::tree::DomTree,
|
||||||
layout: &obscura_render::DomLayout,
|
layout: &obscura_render::DomLayout,
|
||||||
needle: &str,
|
needle: &str,
|
||||||
) -> Option<(f32, f32)> {
|
) -> Option<(f32, f32)> {
|
||||||
for (id, runs) in &layout.text_runs {
|
let mut best: Option<(f32, obscura_render::Rect)> = None;
|
||||||
if let Some(node) = tree.get_node(*id) {
|
for (id, rect) in &layout.rects {
|
||||||
if let NodeData::Text { contents } = &node.data {
|
if tree.text_content(*id).contains(needle) {
|
||||||
if contents.trim() == needle {
|
let area = rect.width * rect.height;
|
||||||
let x = runs.iter().map(|(r, _)| r.x).fold(f32::INFINITY, f32::min);
|
if best.as_ref().map(|(a, _)| area < *a).unwrap_or(true) {
|
||||||
let y = runs.iter().map(|(r, _)| r.y).fold(f32::INFINITY, f32::min);
|
best = Some((area, *rect));
|
||||||
return Some((x, y));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
best.map(|(_, r)| (r.x, r.y))
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -90,29 +89,43 @@ fn hn_shaped_table_lays_out_without_site_hardcoding() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_units_resolve_against_viewport_and_font_size() {
|
||||||
|
// 50vw of a 1000px viewport = 500px; 10em at the default 16px = 160px.
|
||||||
|
// Both were previously mis-resolved (vw kept as raw px, em hardcoded to 16
|
||||||
|
// regardless of context), so this guards the deferred-resolution pass.
|
||||||
|
let html = r##"<div style="width:50vw;height:10em"></div>"##;
|
||||||
|
let tree = parse_html(html);
|
||||||
|
let layout = layout_dom(&tree, (1000.0, 800.0));
|
||||||
|
let hit = layout.rects.values().any(|r| (r.width - 500.0).abs() < 1.0 && (r.height - 160.0).abs() < 1.0);
|
||||||
|
assert!(hit, "expected a 500x160 box from 50vw/10em, rects: {:?}", layout.rects.values().map(|r| (r.width, r.height)).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn long_text_run_wraps_across_multiple_lines() {
|
fn long_text_run_wraps_across_multiple_lines() {
|
||||||
// A single text node with no inline elements breaking it up must still
|
// A long single text node with no inline elements breaking it up must
|
||||||
// wrap word by word within a narrow container: this is the regression
|
// wrap within a narrow container instead of overflowing on one line. The
|
||||||
// case for treating a whole text node as one indivisible layout box,
|
// container's height is the mode-independent proof: several wrapped lines
|
||||||
// which cannot wrap internally and instead overflows straight past the
|
// make it much taller than one line, whether text is shaped by cosmic-text
|
||||||
// container's edge.
|
// (paint) or split into word boxes (layout-only).
|
||||||
let html = r##"<div style="width:100px">This sentence has plenty of words to wrap across several lines</div>"##;
|
let html = r##"<div style="width:100px">This sentence has plenty of words to wrap across several lines</div>"##;
|
||||||
let tree = parse_html(html);
|
let tree = parse_html(html);
|
||||||
let layout = layout_dom(&tree, (1000.0, 1000.0));
|
let layout = layout_dom(&tree, (1000.0, 1000.0));
|
||||||
|
|
||||||
let text_id = tree
|
// Tightest element enclosing the text: the 100px div itself.
|
||||||
.descendants(tree.document())
|
let mut div_rect: Option<obscura_render::Rect> = None;
|
||||||
.into_iter()
|
for (id, rect) in &layout.rects {
|
||||||
.find(|id| matches!(tree.get_node(*id).map(|n| n.data.clone()), Some(NodeData::Text { .. })))
|
if tree.text_content(*id).contains("several lines") {
|
||||||
.expect("text node exists");
|
if div_rect.as_ref().map(|d| rect.width * rect.height < d.width * d.height).unwrap_or(true) {
|
||||||
let runs = layout.text_runs.get(&text_id).expect("text node has word runs");
|
div_rect = Some(*rect);
|
||||||
assert!(runs.len() > 5, "expected the sentence to split into several word leaves, got {}", runs.len());
|
}
|
||||||
|
}
|
||||||
let distinct_y: std::collections::BTreeSet<i32> = runs.iter().map(|(r, _)| r.y.round() as i32).collect();
|
}
|
||||||
|
let div_rect = div_rect.expect("text container laid out");
|
||||||
|
assert!(div_rect.width <= 101.0, "container should hold its 100px width, got {}", div_rect.width);
|
||||||
assert!(
|
assert!(
|
||||||
distinct_y.len() > 1,
|
div_rect.height > 60.0,
|
||||||
"words should wrap onto more than one line within a 100px-wide container, got y positions {:?}",
|
"text should wrap onto several lines in a 100px-wide box (tall container), got height {}",
|
||||||
distinct_y
|
div_rect.height
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user