diff --git a/Cargo.lock b/Cargo.lock index bdff8ce..e42ad3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.13.0", + "bitflags", "cexpr", "clang-sys", "itertools", @@ -238,7 +238,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.2", "shlex 1.3.0", "syn", ] @@ -249,14 +249,14 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags", "cexpr", "clang-sys", "itertools", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.2", "shlex 1.3.0", "syn", ] @@ -276,12 +276,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.0" @@ -345,7 +339,7 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c5e60b8c8d282c86360cab651ded04ab0335a7b5390c8d34145cbeab8cacf5f" dependencies = [ - "bitflags 2.13.0", + "bitflags", "btls-sys", "foreign-types", "libc", @@ -375,6 +369,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "byteorder" @@ -573,6 +581,29 @@ dependencies = [ "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]] name = "cpufeatures" version = "0.2.17" @@ -591,6 +622,31 @@ dependencies = [ "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]] name = "crypto-common" version = "0.1.7" @@ -915,6 +971,28 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "fontdb" version = "0.23.0" @@ -924,7 +1002,7 @@ dependencies = [ "log", "slotmap", "tinyvec", - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -1170,9 +1248,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "grid" -version = "0.15.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36119f3a540b086b4e436bb2b588cf98a68863470e0e880f4d0842f112a3183a" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" [[package]] name = "gzip-header" @@ -1482,16 +1560,18 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "image" -version = "0.24.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", - "byteorder", - "color_quant", - "jpeg-decoder", + "byteorder-lite", + "image-webp", + "moxcms", "num-traits", - "png 0.17.16", + "png", + "zune-core", + "zune-jpeg", ] [[package]] @@ -1569,12 +1649,6 @@ dependencies = [ "libc", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" - [[package]] name = "js-sys" version = "0.3.102" @@ -1726,6 +1800,15 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1753,6 +1836,16 @@ dependencies = [ "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]] name = "new_debug_unreachable" version = "1.0.6" @@ -1972,6 +2065,7 @@ version = "0.1.0" dependencies = [ "ab_glyph", "base64", + "cosmic-text", "cssparser", "image", "obscura-dom", @@ -2024,7 +2118,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -2172,26 +2266,13 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -2291,6 +2372,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quick-error" version = "2.0.1" @@ -2308,7 +2395,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -2328,7 +2415,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -2426,6 +2513,32 @@ dependencies = [ "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]] name = "rcgen" version = "0.13.2" @@ -2439,13 +2552,23 @@ dependencies = [ "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]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -2564,6 +2687,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2576,7 +2705,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2589,7 +2718,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2638,20 +2767,37 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rustybuzz" version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.13.0", + "bitflags", "bytemuck", "core_maths", "log", "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", + "ttf-parser 0.25.1", + "unicode-bidi-mirroring 0.4.0", + "unicode-ccc 0.4.0", "unicode-properties", "unicode-script", ] @@ -2674,7 +2820,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" dependencies = [ - "bitflags 2.13.0", + "bitflags", "cssparser", "derive_more", "fxhash", @@ -2687,6 +2833,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.28" @@ -2846,6 +2998,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "slab" version = "0.4.12" @@ -2888,7 +3050,7 @@ dependencies = [ "data-encoding", "debugid", "if_chain", - "rustc-hash", + "rustc-hash 2.1.2", "serde", "serde_json", "unicode-id-start", @@ -2990,6 +3152,17 @@ dependencies = [ "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]] name = "syn" version = "2.0.117" @@ -3021,6 +3194,15 @@ dependencies = [ "syn", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "sys_traits" version = "0.1.28" @@ -3043,9 +3225,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.7.7" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab4f4d046dd956a47a7e1a2947083d7ac3e6aa3cfaaead36173ceaa5ab11878c" +checksum = "73afc801dd6bd47529eaa7c7e90557f107527d1b7c9c7ed7d7803c7b8d0c357f" dependencies = [ "arrayvec", "grid", @@ -3162,7 +3344,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png 0.18.1", + "png", "tiny-skia-path", ] @@ -3309,7 +3491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.13.0", + "bitflags", "bytes", "futures-core", "futures-util", @@ -3404,6 +3586,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "ttf-parser" version = "0.25.1" @@ -3462,12 +3656,24 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + [[package]] name = "unicode-bidi-mirroring" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + [[package]] name = "unicode-ccc" version = "0.4.0" @@ -3486,6 +3692,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-properties" version = "0.1.4" @@ -3498,6 +3710,12 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-vo" version = "0.1.0" @@ -3563,19 +3781,19 @@ dependencies = [ "base64", "data-url", "flate2", - "fontdb", + "fontdb 0.23.0", "imagesize", "kurbo", "log", "pico-args", "roxmltree", - "rustybuzz", + "rustybuzz 0.20.1", "simplecss", "siphasher", "strict-num", "svgtypes", "tiny-skia-path", - "ttf-parser", + "ttf-parser 0.25.1", "unicode-bidi", "unicode-script", "unicode-vo", @@ -3618,7 +3836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595" dependencies = [ "bindgen 0.71.1", - "bitflags 2.13.0", + "bitflags", "fslock", "gzip-header", "home", @@ -3771,7 +3989,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.13.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -4105,7 +4323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.13.0", + "bitflags", "indexmap", "log", "serde", @@ -4241,6 +4459,12 @@ dependencies = [ "time", ] +[[package]] +name = "yazi" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" + [[package]] name = "yoke" version = "0.8.3" @@ -4264,6 +4488,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeno" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" + [[package]] name = "zerocopy" version = "0.8.52" diff --git a/Cargo.toml b/Cargo.toml index 5ce079d..02be25b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ selectors = "0.26" servo_arc = "0.4" cssparser = "0.34" # 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. tiny-skia = "0.12" # Pure-Rust glyph rasterizer for the render layer's text step. diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index d92320f..05cf2e5 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -655,9 +655,10 @@ mod tests { !err.contains("Unknown Page method"), "captureScreenshot must NOT fall through to the catch-all: {err}" ); + #[cfg(not(feature = "render"))] assert!( - err.contains("not supported by Obscura"), - "error must clearly state screenshot is unsupported: {err}" + err.contains("requires a build with the render feature"), + "error must clearly state screenshot needs the render feature: {err}" ); // Same for the MHTML snapshot sibling method. let err2 = handle("captureSnapshot", &json!({}), &mut ctx, &None) diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index f766045..ea32bf8 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -659,7 +659,11 @@ async fn run_fetch( #[cfg(feature = "render")] { // 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::().ok()).unwrap_or(1280.0); + let shot_h = std::env::var("OBSCURA_SHOT_H").ok().and_then(|v| v.parse::().ok()).unwrap_or(720.0); + match page.screenshot((shot_w, shot_h)) { Some(bytes) => std::fs::write(path, &bytes)?, None => anyhow::bail!("screenshot failed: page has no DOM to render"), } @@ -1819,6 +1823,7 @@ mod tests { quiet: true, output: None, storage_dir: None, + screenshot: None, }); assert!(is_quiet_command(&cmd)); } diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index f611e80..ec6cf65 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -187,6 +187,17 @@ impl<'i> parser::Parser<'i> for ObscuraSelectorParser { 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( &self, _location: cssparser::SourceLocation, diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index d483960..b15060d 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -4527,8 +4527,12 @@ if (typeof TextDecoder === 'undefined') { globalThis.matchMedia = _markNative(function matchMedia(q) { var s = (q || '').toLowerCase().replace(/\s+/g, ''); var matches = false; - if (s.includes('prefers-color-scheme:light')) matches = false; - else if (s.includes('prefers-color-scheme:dark')) matches = true; + // Match headless Chromium's default: light color scheme. Reporting dark + // 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:reduce')) matches = false; else if (s.includes('any-pointer:fine')) matches = true; diff --git a/crates/obscura-render/Cargo.toml b/crates/obscura-render/Cargo.toml index 76f5011..256feb5 100644 --- a/crates/obscura-render/Cargo.toml +++ b/crates/obscura-render/Cargo.toml @@ -8,16 +8,21 @@ description = "Optional scoped render layer for Obscura: layout (taffy) and, wit [features] 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. -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] taffy = { workspace = true } obscura-dom = { workspace = true } tiny-skia = { 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 } base64 = { workspace = true, optional = true } # SVG rasterization for , inline , and background-image diff --git a/crates/obscura-render/assets/dejavu-sans-bold.ttf b/crates/obscura-render/assets/dejavu-sans-bold.ttf new file mode 100644 index 0000000..426812b Binary files /dev/null and b/crates/obscura-render/assets/dejavu-sans-bold.ttf differ diff --git a/crates/obscura-render/assets/dejavu-sans-boldoblique.ttf b/crates/obscura-render/assets/dejavu-sans-boldoblique.ttf new file mode 100644 index 0000000..b8886cb Binary files /dev/null and b/crates/obscura-render/assets/dejavu-sans-boldoblique.ttf differ diff --git a/crates/obscura-render/assets/dejavu-sans-oblique.ttf b/crates/obscura-render/assets/dejavu-sans-oblique.ttf new file mode 100644 index 0000000..300ea68 Binary files /dev/null and b/crates/obscura-render/assets/dejavu-sans-oblique.ttf differ diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 23b7d04..ad84253 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -27,6 +27,17 @@ pub struct Stylesheet { by_class: HashMap>, by_local: HashMap>, universal: Vec, + /// `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 { @@ -39,10 +50,25 @@ impl Stylesheet { by_class: HashMap::new(), by_local: HashMap::new(), universal: Vec::new(), + before_content: Vec::new(), + after_content: Vec::new(), }; let mut order = 0usize; for src in sources { 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 idx = sheet.rules.len(); match sel.key() { @@ -58,6 +84,28 @@ impl Stylesheet { 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, Option) { + 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 { self.rules.is_empty() } @@ -70,6 +118,10 @@ impl Stylesheet { /// Apply every author rule that matches `nid` to `style`, in cascade order /// (ascending specificity, then source order, so the winner is applied last). /// `id`, `classes`, and `local` are the element's precomputed keys. + /// `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( &self, tree: &DomTree, @@ -79,7 +131,8 @@ impl Stylesheet { classes: &[String], local: &str, style: &mut LayoutStyle, - ) { + parent_props: &HashMap, + ) -> Option> { // (specificity, order, rule index) for each matching rule. let mut matched: Vec<(u32, usize, usize)> = Vec::new(); let mut consider = |bucket: Option<&Vec>, matched: &mut Vec<(u32, usize, usize)>| { @@ -105,18 +158,185 @@ impl Stylesheet { } if matched.is_empty() { - return; + return None; } 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 { - 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, 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 { + 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 /// 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. pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> { let mut rules = Vec::new(); @@ -145,6 +365,12 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> { current_decls.push(c); } 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 == '}' { block_depth -= 1; if block_depth == 0 { @@ -153,18 +379,23 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> { if let Some(at) = sel.strip_prefix('@') { flush_at_rule(at, sel, decls, &mut rules); } else { - for s in sel.split(',') { - let s = s.trim(); - if !s.is_empty() { - rules.push((s.to_string(), decls.to_string())); - } - } + // The body may contain nested rules (CSS Nesting, ubiquitous + // in Tailwind v4 / modern frameworks: `.a{ &:hover{} .b{} }`). + // Flatten them against this selector; denest also handles the + // no-nesting case (just emits the rule's own declarations). + denest(sel, decls, &mut rules); } current_selector.clear(); current_decls.clear(); } else { 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 { current_decls.push(c); } 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") { 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 // 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 /// inside `(feature: value)`, so it's safe to discard them wholesale rather /// than special-case every formatting variant a site might use. -fn media_query_applies(query: &str) -> bool { +pub(crate) fn media_query_applies(query: &str) -> bool { const VIEWPORT_W: f32 = 1280.0; if query.contains("print") { return false; } - let compact: String = query.chars().filter(|c| !c.is_whitespace()).collect(); + 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 ); 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 VIEWPORT_W > px { return false; @@ -211,9 +497,232 @@ fn media_query_applies(query: &str) -> bool { 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 } +/// 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 = 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 = 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 = 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::, 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 { + let mut out = Vec::new(); + let mut depth_paren = 0i32; + let mut depth_brack = 0i32; + let mut quote: Option = 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 /// whitespace-stripped `s`, so the first non-digit character always ends the /// number. Handles a bare px value (`750px`) and the extremely common diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 3c928ac..3b39c65 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -47,6 +47,14 @@ pub struct DomLayout { /// of (box, word text) pairs rather than a single box, unlike every other /// node kind. Keyed by the text node's `NodeId`, in layout order. pub text_runs: HashMap>, + /// Inline formatting contexts that were shaped by cosmic-text (see + /// `inline`): a single leaf per container, its glyphs held in + /// `text_engine`. `ifc_items` maps the container's `NodeId` to its item + /// index. Paint rasterizes these instead of the per-word `text_runs`. + #[cfg(feature = "paint")] + pub text_engine: crate::inline::TextEngine, + #[cfg(feature = "paint")] + pub ifc_items: HashMap, } /// Walk the tree top-down accumulating the clip rect imposed by ancestor @@ -88,34 +96,71 @@ fn cascade_walk( sheet: &crate::css::Stylesheet, matcher: &mut obscura_dom::selector::Matcher, styles: &mut HashMap, + parent_props: &std::rc::Rc>, + node_props: &mut HashMap>>, ) { let Some(node) = tree.get_node(id) else { return }; - let is_element = node.as_element().map(|elem| { + let is_element = node.is_element(); + // The custom-property map in force for this node's subtree: the parent's, + // unless this element declares its own `--x` (then a richer map). + let mut this_props = parent_props.clone(); + if let Some(elem) = node.as_element() { let mut style = crate::style::ua_style(elem.local.as_ref()); + // UA rule `[hidden]:not([hidden=until-found]) { display: none }`. + // Applied before the author cascade so a matching author `display` + // still wins (UA origin, per the HTML rendering spec). Sites ship + // dialogs, tab panels, and menus with a plain `hidden` attribute and + // reveal them by removing it (eBay's homepage interstitial uses + // `.lightbox-dialog[role=dialog]:not([hidden]){display:flex}`), so + // without this the hidden overlay paints its full-page dark backdrop. + if let Some(h) = node.get_attribute("hidden") { + if !h.eq_ignore_ascii_case("until-found") { + style.display = crate::Display::None; + } + } if !sheet.is_empty() { let node_id = node.get_attribute("id"); let classes: Vec = node .get_attribute("class") .map(|c| c.split_whitespace().map(|s| s.to_string()).collect()) .unwrap_or_default(); - sheet.apply(tree, matcher, id, node_id, &classes, elem.local.as_ref(), &mut style); + if let Some(m) = sheet.apply(tree, matcher, id, node_id, &classes, elem.local.as_ref(), &mut style, parent_props) { + this_props = std::rc::Rc::new(m); + } } + let (before, after) = sheet.pseudo_content(tree, matcher, id); + style.before_content = before; + style.after_content = after; styles.insert(id, style); - }); + node_props.insert(id, this_props.clone()); + } - if is_element.is_some() { + if is_element { matcher.push_ancestor(tree, id); } for cid in tree.children(id) { - cascade_walk(tree, cid, sheet, matcher, styles); + cascade_walk(tree, cid, sheet, matcher, styles, &this_props, node_props); } - if is_element.is_some() { + if is_element { matcher.pop_ancestor(); } } /// Lay out a DOM tree within `viewport` (width, height) in CSS pixels. pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout { + layout_dom_with_images(tree, viewport, &HashMap::new()) +} + +/// Like [`layout_dom`], but `intrinsic` supplies fetched intrinsic pixel sizes +/// (width, height) for replaced elements keyed by `NodeId`. Paint collects +/// these before layout (it has the base URL and the image cache) so a +/// CSS-sized `` with no width/height attribute gets a real box from its +/// intrinsic ratio instead of collapsing to zero area. +pub fn layout_dom_with_images( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, +) -> DomLayout { let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); // Collect the text of every