diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67bc4e0..59c87e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,15 @@ jobs: run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev - run: cargo build --workspace + build-features-powerset: + name: "Build [anyrender_serialize with all feature combinations]" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-hack + - run: cargo hack check --feature-powerset -p anyrender_serialize + fmt: name: Rustfmt runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index f7c4c0a..c98bb8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -88,6 +103,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyrender" version = "0.7.0" @@ -104,11 +169,15 @@ version = "0.1.0" dependencies = [ "anyrender", "image", + "klippa", "kurbo", "peniko", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", "serde", "serde_json", "sha2", + "ttf2woff2", + "wuff", "zip", ] @@ -354,6 +423,27 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -397,6 +487,12 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -500,6 +596,46 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "cocoa" version = "0.25.0" @@ -567,6 +703,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "com" version = "0.6.0" @@ -1009,6 +1151,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1021,11 +1169,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" + [[package]] name = "font-types" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "511e2c18a516c666d27867d2f9821f76e7d591f762e9fc41dd6cc5c90fe54b0b" +dependencies = [ + "bytemuck", +] [[package]] name = "font-types" @@ -1036,6 +1193,14 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.11.0" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -1059,6 +1224,21 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "fontique" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bbc252c93499b6d3635d692f892a637db0dbb130ce9b32bf20b28e0dcc470b" +dependencies = [ + "bytemuck", + "hashbrown 0.16.1", + "icu_locale_core", + "linebender_resource_handle", + "memmap2", + "read-fonts 0.35.0", + "smallvec", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1364,6 +1544,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1380,6 +1573,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -1427,6 +1622,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", +] + [[package]] name = "image" version = "0.25.8" @@ -1471,6 +1679,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1545,6 +1759,20 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "klippa" +version = "0.1.0" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "clap", + "fnv", + "hashbrown 0.15.5", + "regex", + "skrifa 0.40.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", + "thiserror 1.0.69", + "write-fonts", +] + [[package]] name = "kurbo" version = "0.13.0" @@ -1628,6 +1856,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "litrs" version = "1.0.0" @@ -2173,6 +2407,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "orbclient" version = "0.3.48" @@ -2232,6 +2472,20 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parley" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada5338c3a9794af7342e6f765b6e78740db37378aced034d7bf72c96b94ed94" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.16.1", + "linebender_resource_handle", + "skrifa 0.37.0", + "swash", +] + [[package]] name = "paste" version = "1.0.15" @@ -2509,6 +2763,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.0", +] + [[package]] name = "read-fonts" version = "0.37.0" @@ -2516,7 +2781,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" dependencies = [ "bytemuck", - "font-types 0.11.0", + "font-types 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "bytemuck", + "font-types 0.11.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", ] [[package]] @@ -2769,6 +3043,7 @@ dependencies = [ "anyrender_vello_cpu", "image", "kurbo", + "parley", "peniko", ] @@ -2837,6 +3112,16 @@ dependencies = [ "skia-bindings", ] +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + [[package]] name = "skrifa" version = "0.40.0" @@ -2844,7 +3129,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.37.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", ] [[package]] @@ -2970,6 +3264,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -2986,6 +3286,17 @@ dependencies = [ "siphasher", ] +[[package]] +name = "swash" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" +dependencies = [ + "skrifa 0.37.0", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "1.0.109" @@ -3115,6 +3426,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -3206,6 +3528,18 @@ dependencies = [ "core_maths", ] +[[package]] +name = "ttf2woff2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8ef4bdeee0ac1cec411193a14bfe665098d9409856da6aedb5177b11eb8d052" +dependencies = [ + "brotli", + "byteorder", + "clap", + "thiserror 2.0.17", +] + [[package]] name = "typenum" version = "1.19.0" @@ -3314,6 +3648,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vello" version = "0.7.0" @@ -3325,7 +3665,7 @@ dependencies = [ "log", "peniko", "png 0.17.16", - "skrifa", + "skrifa 0.40.0 (registry+https://github.com/rust-lang/crates.io-index)", "static_assertions", "thiserror 2.0.17", "vello_encoding", @@ -3345,7 +3685,7 @@ dependencies = [ "log", "peniko", "png 0.17.16", - "skrifa", + "skrifa 0.40.0 (registry+https://github.com/rust-lang/crates.io-index)", "smallvec", ] @@ -3373,7 +3713,7 @@ dependencies = [ "bytemuck", "guillotiere", "peniko", - "skrifa", + "skrifa 0.40.0 (registry+https://github.com/rust-lang/crates.io-index)", "smallvec", ] @@ -4344,6 +4684,37 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "write-fonts" +version = "0.45.0" +source = "git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826#41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" +dependencies = [ + "font-types 0.11.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", + "indexmap", + "kurbo", + "log", + "read-fonts 0.37.0 (git+https://github.com/googlefonts/fontations?rev=41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826)", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wuff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088845d3772b9624d010137410e44bbdbf60a13ecf39338b7617723c29eb4afd" +dependencies = [ + "arrayvec", + "brotli-decompressor", + "bytes", + "flate2", + "font-types 0.9.0", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -4423,6 +4794,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.8.27" @@ -4443,6 +4826,22 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "serde", + "zerofrom", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index 8a67b5c..27f5786 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,10 @@ serde = "1.0.228" serde_json = "1.0" zip = { version = "2.1", default-features = false, features = ["deflate"] } sha2 = "0.10" +klippa = { git = "https://github.com/googlefonts/fontations", rev = "41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" } +read-fonts = { git = "https://github.com/googlefonts/fontations", rev = "41ec2bdd6dd8589b65eaaaf182b0a2e701d0d826" } +ttf2woff2 = "0.11" +wuff = "0.2" # Linebender color = "0.3" diff --git a/crates/anyrender_serialize/Cargo.toml b/crates/anyrender_serialize/Cargo.toml index 9a3df8a..13035e3 100644 --- a/crates/anyrender_serialize/Cargo.toml +++ b/crates/anyrender_serialize/Cargo.toml @@ -8,6 +8,11 @@ repository.workspace = true license.workspace = true edition.workspace = true +[features] +default = [] +subsetting = ["dep:klippa", "dep:read-fonts"] +woff2 = ["dep:ttf2woff2"] + [dependencies] anyrender = { workspace = true, features = ["serde"] } peniko = { workspace = true } @@ -17,9 +22,15 @@ serde_json = { workspace = true } zip = { workspace = true } sha2 = { workspace = true } image = { workspace = true, features = ["png"] } +klippa = { workspace = true, optional = true } +read-fonts = { workspace = true, optional = true } +ttf2woff2 = { workspace = true, optional = true } +wuff = { workspace = true } [dev-dependencies] kurbo = { workspace = true } peniko = { workspace = true } +read-fonts = { workspace = true } serde_json = { workspace = true } +wuff = { workspace = true } zip = { workspace = true } diff --git a/crates/anyrender_serialize/src/font_writer.rs b/crates/anyrender_serialize/src/font_writer.rs new file mode 100644 index 0000000..acab718 --- /dev/null +++ b/crates/anyrender_serialize/src/font_writer.rs @@ -0,0 +1,181 @@ +//! Write-side font processing: collection, deduplication, subsetting, and encoding. + +use std::collections::HashMap; +#[cfg(feature = "subsetting")] +use std::collections::HashSet; + +#[cfg(feature = "subsetting")] +use klippa::{Plan, SubsetFlags}; +use peniko::FontData; +#[cfg(feature = "subsetting")] +use read_fonts::FontRef; +#[cfg(feature = "subsetting")] +use read_fonts::collections::int_set::IntSet; +#[cfg(feature = "subsetting")] +use read_fonts::types::GlyphId; + +use crate::{ArchiveError, ResourceId, sha256_hex}; + +/// A font that has been processed (optionally subsetted and/or WOFF2-encoded) and is +/// ready to be written into the archive. +pub(crate) struct ProcessedFont { + /// Size of the raw (uncompressed) font data in bytes. + pub raw_size: usize, + /// The stored font data (WOFF2-compressed or raw TTF/OTF depending on features). + pub stored_data: Vec, + /// SHA-256 hex hash of `stored_data`. + pub hash: String, + /// Archive-relative path (e.g. `fonts/.woff2` or `fonts/.ttf`). + pub path: String, +} + +/// Collects, deduplicates, and processes fonts for writing into a scene archive. +/// +/// When the `subsetting` feature is enabled, each `(blob, face index)` pair is treated +/// as a distinct resource because subsetting extracts each face into a standalone font. +/// +/// When disabled, fonts are deduplicated by blob alone. Multiple faces sharing the same TTC +/// are stored together. +pub(crate) struct FontWriter { + /// Map `(Blob ID, face index)` to [`ResourceId`]. + #[cfg(feature = "subsetting")] + id_map: HashMap<(u64, u32), ResourceId>, + /// Map `Blob ID` to [`ResourceId`]. + #[cfg(not(feature = "subsetting"))] + id_map: HashMap, + + fonts: Vec, + + #[cfg(feature = "subsetting")] + glyph_ids: Vec>, +} + +impl FontWriter { + pub fn new() -> Self { + Self { + id_map: HashMap::new(), + fonts: Vec::new(), + #[cfg(feature = "subsetting")] + glyph_ids: Vec::new(), + } + } + + /// Register a font and return its [`ResourceId`]. + pub fn register(&mut self, font: &FontData) -> ResourceId { + #[cfg(feature = "subsetting")] + let key = (font.data.id(), font.index); + #[cfg(not(feature = "subsetting"))] + let key = font.data.id(); + + if let Some(&id) = self.id_map.get(&key) { + return id; + } + + let id = ResourceId(self.fonts.len()); + self.id_map.insert(key, id); + self.fonts.push(font.clone()); + #[cfg(feature = "subsetting")] + self.glyph_ids.push(HashSet::new()); + id + } + + /// Record glyph IDs used for a font resource (used for subsetting). + pub fn record_glyphs(&mut self, id: ResourceId, glyphs: &[anyrender::Glyph]) { + #[cfg(feature = "subsetting")] + { + let glyph_set = &mut self.glyph_ids[id.0]; + for glyph in glyphs { + glyph_set.insert(glyph.id); + } + } + #[cfg(not(feature = "subsetting"))] + { + let _ = (id, glyphs); + } + } + + /// The face index to store in [`crate::FontResourceId`]. + /// + /// When subsetting is enabled, faces are extracted into standalone fonts so the index + /// is always 0. Otherwise the original face index is preserved. + pub fn face_index(&self, font: &FontData) -> u32 { + #[cfg(feature = "subsetting")] + { + let _ = font; + 0 + } + #[cfg(not(feature = "subsetting"))] + { + font.index + } + } + + /// Consume the writer, returning an iterator of processed fonts ready for the archive. + pub fn into_processed(self) -> impl Iterator> { + #[cfg(feature = "subsetting")] + let glyph_ids = self.glyph_ids; + + self.fonts.into_iter().enumerate().map(move |(_idx, font)| { + // Conditionally subset. + #[cfg(feature = "subsetting")] + let raw_data = { + let font_glyph_ids = &glyph_ids[_idx]; + + let font_ref = FontRef::from_index(font.data.data(), font.index).map_err(|e| { + ArchiveError::FontProcessing(format!("Failed to parse font: {e}")) + })?; + + let mut input_gids: IntSet = IntSet::empty(); + for &gid in font_glyph_ids { + input_gids.insert(GlyphId::new(gid)); + } + + let plan = Plan::new( + &input_gids, + &IntSet::empty(), + &font_ref, + // Keep original glyph IDs so we don't need to remap them in draw commands. + SubsetFlags::SUBSET_FLAGS_RETAIN_GIDS, + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + &IntSet::empty(), + ); + + klippa::subset_font(&font_ref, &plan).map_err(|e| { + ArchiveError::FontProcessing(format!("Font subsetting failed: {e}")) + })? + }; + #[cfg(not(feature = "subsetting"))] + let raw_data = font.data.data().to_vec(); + + let raw_size = raw_data.len(); + + // Conditionally WOFF2 compress. + #[cfg(feature = "woff2")] + let stored_data = + ttf2woff2::encode_no_transform(&raw_data, ttf2woff2::BrotliQuality::default()) + .map_err(|e| { + ArchiveError::FontProcessing(format!("WOFF2 encoding failed: {e}")) + })?; + #[cfg(not(feature = "woff2"))] + let stored_data = raw_data; + + let hash = sha256_hex(&stored_data); + let extension = if cfg!(feature = "woff2") { + "woff2" + } else { + "ttf" + }; + let path = format!("fonts/{}.{}", hash, extension); + + Ok(ProcessedFont { + raw_size, + stored_data, + hash, + path, + }) + }) + } +} diff --git a/crates/anyrender_serialize/src/lib.rs b/crates/anyrender_serialize/src/lib.rs index a9778f5..53f7589 100644 --- a/crates/anyrender_serialize/src/lib.rs +++ b/crates/anyrender_serialize/src/lib.rs @@ -7,7 +7,7 @@ //! - `resources.json` - Metadata mapping resource files to IDs //! - `draw_commands.json` - Serialized draw commands referencing resources by ID //! - `images/.png` - Image files (PNG format) -//! - `fonts/.ttf` - Font data files (TTF format) +//! - `fonts/.{woff2,ttf}` - Font data files (optionally WOFF2-compressed and subsetted) use std::collections::HashMap; use std::io::{Read, Seek, Write}; @@ -21,8 +21,11 @@ use zip::{ZipArchive, ZipWriter}; use anyrender::recording::{FillCommand, GlyphRunCommand, RenderCommand, Scene, StrokeCommand}; +mod font_writer; mod json_formatter; +use font_writer::FontWriter; + /// A render command with resources replaced by IDs. pub type SerializableRenderCommand = RenderCommand; @@ -49,6 +52,7 @@ pub struct FontResourceId { pub struct SceneArchive { pub manifest: ResourceManifest, pub commands: Vec, + /// Font data (one per font resource, optionally WOFF2-compressed and/or subsetted). pub fonts: Vec>, pub images: Vec, } @@ -93,9 +97,9 @@ pub struct ImageMetadata { /// Metadata for a font resource. /// -/// The font resource represents the raw font file data (which may be a font -/// collection containing multiple faces). The collection index is stored in -/// the drawing commands. +/// When the `woff2` feature is enabled, fonts are WOFF2-compressed. +/// When the `subsetting` feature is enabled, TTC fonts are extracted to +/// standalone fonts and subsetted to only the glyphs used. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FontMetadata { #[serde(flatten)] @@ -125,12 +129,9 @@ pub enum ResourceKind { /// Collects and deduplicates resources from a scene. struct ResourceCollector { - /// Maps Blob ID to ResourceId for fonts - font_id_map: HashMap, + fonts: FontWriter, /// Maps Blob ID to ResourceId for images image_id_map: HashMap, - /// Collected font file blobs - fonts: Vec>, /// Collected images images: Vec, } @@ -138,26 +139,12 @@ struct ResourceCollector { impl ResourceCollector { fn new() -> Self { Self { - font_id_map: HashMap::new(), + fonts: FontWriter::new(), image_id_map: HashMap::new(), - fonts: Vec::new(), images: Vec::new(), } } - /// Register a font and return its [`ResourceId`]. - fn register_font(&mut self, font: &FontData) -> ResourceId { - let blob_id = font.data.id(); - if let Some(&id) = self.font_id_map.get(&blob_id) { - return id; - } - - let id = ResourceId(self.fonts.len()); - self.font_id_map.insert(blob_id, id); - self.fonts.push(font.data.clone()); - id - } - /// Register an image and return its [`ResourceId`]. fn register_image(&mut self, image: &ImageData) -> ResourceId { let blob_id = image.data.id(); @@ -209,12 +196,13 @@ impl ResourceCollector { shape: fill.shape.clone(), }), RenderCommand::GlyphRun(glyph_run) => { - let resource_id = self.register_font(&glyph_run.font_data); + let resource_id = self.fonts.register(&glyph_run.font_data); + self.fonts.record_glyphs(resource_id, &glyph_run.glyphs); let brush = self.convert_brush(&glyph_run.brush); SerializableRenderCommand::GlyphRun(GlyphRunCommand { font_data: FontResourceId { resource_id, - index: glyph_run.font_data.index, + index: self.fonts.face_index(&glyph_run.font_data), }, font_size: glyph_run.font_size, hint: glyph_run.hint, @@ -236,17 +224,17 @@ impl ResourceCollector { /// Reconstructs resources from deserialized data. struct ResourceReconstructor { - font_blobs: Vec>, + fonts: Vec, images: Vec, } impl ResourceReconstructor { - fn new(font_blobs: Vec>, images: Vec) -> Self { - Self { font_blobs, images } + fn new(fonts: Vec, images: Vec) -> Self { + Self { fonts, images } } - fn get_font_blob(&self, id: ResourceId) -> Result<&Blob, ArchiveError> { - self.font_blobs + fn get_font(&self, id: ResourceId) -> Result<&FontData, ArchiveError> { + self.fonts .get(id.0) .ok_or(ArchiveError::ResourceNotFound(id)) } @@ -298,10 +286,8 @@ impl ResourceReconstructor { shape: fill.shape.clone(), }), SerializableRenderCommand::GlyphRun(glyph_run) => { - let font_data = FontData::new( - self.get_font_blob(glyph_run.font_data.resource_id)?.clone(), - glyph_run.font_data.index, - ); + let font = self.get_font(glyph_run.font_data.resource_id)?; + let font_data = FontData::new(font.data.clone(), glyph_run.font_data.index); let brush = self.convert_brush(&glyph_run.brush)?; RenderCommand::GlyphRun(GlyphRunCommand { font_data, @@ -323,7 +309,7 @@ impl ResourceReconstructor { } } -fn sha256_hex(data: &[u8]) -> String { +pub(crate) fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); let result = hasher.finalize(); @@ -443,27 +429,26 @@ impl SceneArchive { }); } - // Add font metadata - for (idx, blob) in collector.fonts.iter().enumerate() { - let data = blob.data(); - let hash = sha256_hex(data); - let path = format!("fonts/{}.ttf", hash); - + // Add font metadata. + let mut fonts = Vec::new(); + for (idx, result) in collector.fonts.into_processed().enumerate() { + let font = result?; manifest.fonts.push(FontMetadata { entry: ResourceEntry { id: ResourceId(idx), kind: ResourceKind::Font, - size: data.len(), - sha256_hash: hash, - path, + size: font.raw_size, + sha256_hash: font.hash, + path: font.path, }, }); + fonts.push(Blob::from(font.stored_data)); } Ok(Self { manifest, commands, - fonts: collector.fonts, + fonts, images, }) } @@ -487,7 +472,24 @@ impl SceneArchive { }) .collect::, ArchiveError>>()?; - let reconstructor = ResourceReconstructor::new(self.fonts.clone(), images); + // Decode fonts. + let fonts_ttf: Vec = self + .fonts + .iter() + .map(|font_blob| { + let data = font_blob.data(); + let ttf_data = if data.starts_with(b"wOF2") { + wuff::decompress_woff2(data).map_err(|e| { + ArchiveError::FontProcessing(format!("WOFF2 decoding failed: {e}")) + })? + } else { + data.to_vec() + }; + Ok(FontData::new(Blob::from(ttf_data), 0)) + }) + .collect::, ArchiveError>>()?; + + let reconstructor = ResourceReconstructor::new(fonts_ttf, images); let commands: Result, _> = self .commands @@ -529,10 +531,10 @@ impl SceneArchive { } // Write font files - for (idx, blob) in self.fonts.iter().enumerate() { + for (idx, font_data) in self.fonts.iter().enumerate() { let path = &self.manifest.fonts[idx].entry.path; zip.start_file(path, options)?; - zip.write_all(blob.data())?; + zip.write_all(font_data.data())?; } zip.finish()?; @@ -552,7 +554,7 @@ impl SceneArchive { }; // Check version - if manifest.version > ResourceManifest::CURRENT_VERSION { + if manifest.version != ResourceManifest::CURRENT_VERSION { return Err(ArchiveError::UnsupportedVersion(manifest.version)); } @@ -590,23 +592,22 @@ impl SceneArchive { }); } - // Read fonts - let mut fonts = Vec::with_capacity(manifest.fonts.len()); + // Read fonts (may be WOFF2-compressed or raw TTF/OTF) + let mut fonts: Vec> = Vec::with_capacity(manifest.fonts.len()); for meta in &manifest.fonts { let mut file = zip.by_name(&meta.entry.path)?; - let mut data = Vec::with_capacity(meta.entry.size); - file.read_to_end(&mut data)?; + let mut raw_data = Vec::new(); + file.read_to_end(&mut raw_data)?; // Verify hash - let hash = sha256_hex(&data); + let hash = sha256_hex(&raw_data); if hash != meta.entry.sha256_hash { return Err(ArchiveError::InvalidFormat(format!( "Hash mismatch for {}: expected {}, got {}", meta.entry.path, meta.entry.sha256_hash, hash ))); } - - fonts.push(Blob::from(data)); + fonts.push(Blob::from(raw_data)); } Ok(Self { @@ -624,6 +625,7 @@ pub enum ArchiveError { Json(serde_json::Error), Zip(zip::result::ZipError), Image(image::ImageError), + FontProcessing(String), InvalidFormat(String), ResourceNotFound(ResourceId), UnsupportedVersion(u32), @@ -636,6 +638,7 @@ impl std::fmt::Display for ArchiveError { ArchiveError::Json(e) => write!(f, "JSON error: {}", e), ArchiveError::Zip(e) => write!(f, "Zip error: {}", e), ArchiveError::Image(e) => write!(f, "Image error: {}", e), + ArchiveError::FontProcessing(msg) => write!(f, "Font processing error: {}", msg), ArchiveError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg), ArchiveError::ResourceNotFound(id) => write!(f, "Resource not found: {:?}", id), ArchiveError::UnsupportedVersion(v) => write!(f, "Unsupported version: {}", v), diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index 29ab4ad..b5b65d4 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -12,6 +12,8 @@ use peniko::{ Blob, Brush, Color, Compose, Fill, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix, }; +#[cfg(all(feature = "subsetting", feature = "woff2"))] +use read_fonts::TableProvider; use zip::ZipArchive; #[test] @@ -203,7 +205,8 @@ fn test_multiple_different_images() { #[test] fn test_glyph_run_roundtrip() { let font = roboto_font(); - let font_bytes = font.data.data().to_vec(); + #[cfg(feature = "subsetting")] + let original_font_size = font.data.data().len(); let mut scene = Scene::new(); let glyphs = [ @@ -249,10 +252,50 @@ fn test_glyph_run_roundtrip() { // Verify font metadata assert_eq!(archive.manifest.fonts.len(), 1); - assert_eq!(archive.manifest.fonts[0].entry.size, font_bytes.len()); - // Verify font bytes - assert_eq!(archive.fonts[0].data(), font_bytes.as_slice()); + #[cfg(feature = "subsetting")] + assert!( + archive.manifest.fonts[0].entry.size < original_font_size, + "Subsetted font ({} bytes) should be smaller than original ({} bytes)", + archive.manifest.fonts[0].entry.size, + original_font_size + ); + + // Verify the WOFF2 file path + #[cfg(feature = "woff2")] + assert!(archive.manifest.fonts[0].entry.path.ends_with(".woff2")); + #[cfg(not(feature = "woff2"))] + assert!(archive.manifest.fonts[0].entry.path.ends_with(".ttf")); + + // Verify subsetting + #[cfg(all(feature = "subsetting", feature = "woff2"))] + { + let ttf_data = wuff::decompress_woff2(archive.fonts[0].data()).unwrap(); + let font_ref = read_fonts::FontRef::new(&ttf_data).unwrap(); + let loca = font_ref.loca(None).unwrap(); + let glyf = font_ref.glyf().unwrap(); + + // The used glyph IDs (43, 72, 79) should have outlines in the subsetted font + for &gid in &[43u32, 72, 79] { + let glyph = loca + .get_glyf(read_fonts::types::GlyphId::new(gid), &glyf) + .unwrap(); + assert!( + glyph.is_some(), + "Glyph {gid} should have an outline in the subsetted font" + ); + } + + // An unused glyph ID should be an empty slot (RETAIN_GIDS preserves IDs + // but removes outlines for glyphs not in the subset) + let unused_glyph = loca + .get_glyf(read_fonts::types::GlyphId::new(50), &glyf) + .unwrap(); + assert!( + unused_glyph.is_none(), + "Glyph 50 should be an empty slot in the subsetted font" + ); + } // Verify the scene roundtrip let restored = archive.to_scene().unwrap(); @@ -260,14 +303,21 @@ fn test_glyph_run_roundtrip() { match &restored.commands[0] { RenderCommand::GlyphRun(glyph_run) => { - assert_eq!(glyph_run.font_data.data.data(), font.data.data()); - assert_eq!(glyph_run.font_data.index, font.index); assert_eq!(glyph_run.font_size, font_size); assert_eq!(glyph_run.hint, hint); assert_eq!(glyph_run.brush_alpha, brush_alpha); assert_eq!(glyph_run.transform, transform); assert_eq!(glyph_run.glyph_transform, glyph_transform); - assert_eq!(glyph_run.glyphs, glyphs); + assert_eq!(glyph_run.font_data.index, 0); // Standalone after subsetting + assert_eq!(glyph_run.glyphs.len(), 3); + // Glyph positions are preserved + assert_eq!(glyph_run.glyphs[0].x, 0.0); + assert_eq!(glyph_run.glyphs[1].x, 10.0); + assert_eq!(glyph_run.glyphs[2].x, 20.0); + // Glyph IDs are preserved (RETAIN_GIDS keeps original IDs) + assert_eq!(glyph_run.glyphs[0].id, 43); + assert_eq!(glyph_run.glyphs[1].id, 72); + assert_eq!(glyph_run.glyphs[2].id, 79); } other => panic!("Expected GlyphRun command, got {other:?}"), } diff --git a/examples/serialize/Cargo.toml b/examples/serialize/Cargo.toml index c436cc2..e81cbde 100644 --- a/examples/serialize/Cargo.toml +++ b/examples/serialize/Cargo.toml @@ -10,5 +10,6 @@ kurbo = { workspace = true } peniko = { workspace = true } image = { workspace = true, features = ["png"] } anyrender = { workspace = true, features = ["serde"] } -anyrender_serialize = { workspace = true } +anyrender_serialize = { workspace = true, features = ["subsetting", "woff2"] } anyrender_vello_cpu = { workspace = true } +parley = { version = "0.7", default-features = false, features = ["std"] } diff --git a/examples/serialize/src/main.rs b/examples/serialize/src/main.rs index 7f25b63..68f288f 100644 --- a/examples/serialize/src/main.rs +++ b/examples/serialize/src/main.rs @@ -5,12 +5,16 @@ use std::io::BufWriter; use std::path::Path; use anyrender::recording::Scene; -use anyrender::{PaintScene, render_to_buffer}; +use anyrender::{Glyph, PaintScene, render_to_buffer}; use anyrender_serialize::SceneArchive; use anyrender_vello_cpu::VelloCpuImageRenderer; use image::{ImageBuffer, RgbaImage}; use kurbo::{Affine, Circle, Point, Rect, RoundedRect, Stroke}; -use peniko::{Blob, Color, Fill, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix}; +use parley::style::{FontFamily, FontStack}; +use parley::{Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, StyleProperty}; +use peniko::{ + Blob, Color, Fill, FontData, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix, +}; const WIDTH: u32 = 400; const HEIGHT: u32 = 300; @@ -91,6 +95,9 @@ fn create_demo_scene() -> Scene { &rounded_card, ); + // Text + draw_text_with_parley(&mut scene); + // Draw some circles using layers with blend modes scene.push_layer( Mix::Multiply, @@ -181,6 +188,93 @@ fn create_demo_scene() -> Scene { scene } +/// Lay out text with parley using the Roboto font and draw it onto the scene. +fn draw_text_with_parley(scene: &mut Scene) { + let mut font_cx = FontContext::new(); + let mut layout_cx = LayoutContext::new(); + + let font_blob = Blob::from(include_bytes!("../../../assets/fonts/roboto/Roboto.ttf").to_vec()); + font_cx.collection.register_fonts(font_blob.clone(), None); + + // Title + { + let text = "Hello World!"; + let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true); + builder.push_default(StyleProperty::FontSize(18.0)); + builder.push_default(StyleProperty::FontStack(FontStack::Single( + FontFamily::Named("Roboto".into()), + ))); + let mut layout: Layout<()> = builder.build(text); + layout.break_all_lines(Some(140.0)); + layout.align(Some(140.0), Alignment::Start, AlignmentOptions::default()); + render_layout( + scene, + &layout, + &font_blob, + Affine::translate((32.0, 50.0)), + Color::from_rgb8(40, 40, 60), + ); + } + // Paragraph + { + let text = + "Serialization roundtrip test: fonts are subsetted, compressed to WOFF2, and restored."; + let mut builder = layout_cx.ranged_builder(&mut font_cx, text, 1.0, true); + builder.push_default(StyleProperty::FontSize(13.0)); + builder.push_default(StyleProperty::FontStack(FontStack::Single( + FontFamily::Named("Roboto".into()), + ))); + let mut layout: Layout<()> = builder.build(text); + layout.break_all_lines(Some(150.0)); + layout.align(Some(150.0), Alignment::Start, AlignmentOptions::default()); + render_layout( + scene, + &layout, + &font_blob, + Affine::translate((32.0, 76.0)), + Color::from_rgb8(80, 80, 100), + ); + } +} + +fn render_layout( + scene: &mut Scene, + layout: &Layout<()>, + font_blob: &Blob, + transform: Affine, + color: Color, +) { + for line in layout.lines() { + for item in line.items() { + if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item { + let run = glyph_run.run(); + let parley_font = run.font(); + let font_data = FontData::new(font_blob.clone(), parley_font.index); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords(); + let glyphs = glyph_run.positioned_glyphs().map(|g| Glyph { + id: g.id, + x: g.x, + y: g.y, + }); + + scene.draw_glyphs( + &font_data, + font_size, + false, + normalized_coords, + Fill::NonZero, + color, + 1.0, + transform, + None, + glyphs.into_iter(), + ); + } + } + } +} + /// Create a checkerboard image for demonstrating image brushes. fn create_checkerboard_image(width: u32, height: u32) -> ImageData { let mut pixels = Vec::with_capacity((width * height * 4) as usize);