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:
SGavrl committed 2026-07-11 19:52:36 +00:00
1 parent 88313178b2
commit 2652ee64a4
17 files changed
+4425 -311

No files matched your search

Generated
+292 -62
View File
@@ -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
View File
@@ -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.
+3 -2
View File
@@ -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)
+6 -1
View File
@@ -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));
} }
+11
View File
@@ -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,
+6 -2
View File
@@ -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 -3
View File
@@ -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.
+521 -12
View File
@@ -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
File diff suppressed because it is too large. Load diff
+532
View File
@@ -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]);
}
}
}
}
}
+296 -28
View File
@@ -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),
} }
} }
+527 -20
View File
@@ -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>,
File diff suppressed because it is too large. Load diff
+45 -32
View File
@@ -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
); );
} }