diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41f17d137..c5c81537b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: sudo dpkg --add-architecture i386 sudo apt update sudo apt install -y gcc-multilib libssl-dev:i386 pkg-config:i386 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.87.0 with: targets: ${{ matrix.bits == 32 && 'i686-unknown-linux-gnu' || '' }} - uses: Swatinem/rust-cache@v2 @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.87.0 with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d235aec5..ca317abd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.87.0 with: target: ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index 06dd4ab80..b699d2450 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,20 @@ name = "bytemuck" version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa76293b4f7bb636ab88fd78228235b5248b4d05cc589aed610f954af5d7c7a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "byteorder" @@ -494,9 +508,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] @@ -735,11 +749,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.35" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -749,6 +764,15 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -761,6 +785,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +[[package]] +name = "font-types" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -829,6 +862,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "getopts" version = "0.2.21" @@ -871,6 +913,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + [[package]] name = "half" version = "2.4.1" @@ -966,7 +1014,7 @@ checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", "serde", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec", ] @@ -1064,7 +1112,7 @@ dependencies = [ "stable_deref_trait", "tinystr", "writeable", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec", ] @@ -1167,6 +1215,7 @@ dependencies = [ "byteorder-lite", "color_quant", "gif", + "image-webp", "num-traits", "png", "zune-core", @@ -1211,6 +1260,12 @@ dependencies = [ "serde", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" + [[package]] name = "inotify" version = "0.11.0" @@ -1310,6 +1365,50 @@ dependencies = [ "libc", ] +[[package]] +name = "krilla" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ee6128ebf52d7ce684613b6431ead2959f2be9ff8cf776eeaaad0427c953e9" +dependencies = [ + "base64", + "bumpalo", + "comemo", + "flate2", + "float-cmp 0.10.0", + "fxhash", + "gif", + "image-webp", + "imagesize", + "once_cell", + "pdf-writer", + "png", + "rayon", + "rustybuzz", + "siphasher", + "skrifa", + "subsetter", + "tiny-skia-path", + "xmp-writer", + "yoke 0.8.0", + "zune-jpeg", +] + +[[package]] +name = "krilla-svg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3462989578155cf620ef8035f8921533cc95c28e2a0c75de172f7219e6aba84e" +dependencies = [ + "flate2", + "fontdb", + "krilla", + "png", + "resvg", + "tiny-skia", + "usvg", +] + [[package]] name = "kurbo" version = "0.11.1" @@ -1371,6 +1470,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902bc563b5d65ad9bba616b490842ef0651066a1a1dc3ce1087113ffcb873c8d" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1458,9 +1566,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", "simd-adler32", @@ -1601,9 +1709,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.70" +version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ "bitflags 2.8.0", "cfg-if", @@ -1642,9 +1750,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.105" +version = "0.9.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" dependencies = [ "cc", "libc", @@ -1738,9 +1846,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pdf-writer" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df03c7d216de06f93f398ef06f1385a60f2c597bb96f8195c8d98e08a26b1d5" +checksum = "3ea27c5015ab81753fc61e49f8cde74999346605ee148bb20008ef3d3150e0dc" dependencies = [ "bitflags 2.8.0", "itoa", @@ -1997,6 +2105,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "600e807b48ac55bad68a8cb75cc3c7739f139b9248f7e003e01e080f589b5288" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -2315,6 +2433,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skrifa" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa1e5622e4f7b98877e8a19890efddcac1230cec6198bd9de91ec0e00010dc8" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slotmap" version = "1.0.7" @@ -2361,7 +2489,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" dependencies = [ - "float-cmp", + "float-cmp 0.9.0", ] [[package]] @@ -2404,28 +2532,11 @@ dependencies = [ [[package]] name = "subsetter" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74f98178f34057d4d4de93d68104007c6dea4dfac930204a69ab4622daefa648" - -[[package]] -name = "svg2pdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e50dc062439cc1a396181059c80932a6e6bd731b130e674c597c0c8874b6df22" +checksum = "35539e8de3dcce8dd0c01f3575f85db1e5ac1aea1b996d2d09d89f148bc91497" dependencies = [ - "fontdb", - "image", - "log", - "miniz_oxide", - "once_cell", - "pdf-writer", - "resvg", - "siphasher", - "subsetter", - "tiny-skia", - "ttf-parser", - "usvg", + "fxhash", ] [[package]] @@ -2753,7 +2864,7 @@ dependencies = [ [[package]] name = "typst-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-assets?rev=ab1295f#ab1295ff896444e51902e03c2669955e1d73604a" +source = "git+https://github.com/typst/typst-assets?rev=c74e539#c74e539b090070a0c66fd007c550f5b6d3b724bd" [[package]] name = "typst-cli" @@ -2803,7 +2914,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-dev-assets?rev=9879589#9879589f4b3247b12c5e694d0d7fa86d4d8a198e" +source = "git+https://github.com/typst/typst-dev-assets?rev=fddbf8b#fddbf8b99506bc370ac0edcd4959add603a7fc92" [[package]] name = "typst-docs" @@ -2928,6 +3039,7 @@ dependencies = [ "icu_provider_blob", "icu_segmenter", "kurbo", + "memchr", "rustybuzz", "smallvec", "ttf-parser", @@ -2958,6 +3070,7 @@ dependencies = [ "ecow", "flate2", "fontdb", + "glidesort", "hayagriva", "icu_properties", "icu_provider", @@ -3000,6 +3113,7 @@ dependencies = [ "unicode-segmentation", "unscanny", "usvg", + "utf8_iter", "wasmi", "xmlwriter", ] @@ -3018,26 +3132,20 @@ dependencies = [ name = "typst-pdf" version = "0.13.1" dependencies = [ - "arrayvec", - "base64", "bytemuck", "comemo", "ecow", "image", - "indexmap 2.7.1", - "miniz_oxide", - "pdf-writer", + "infer", + "krilla", + "krilla-svg", "serde", - "subsetter", - "svg2pdf", - "ttf-parser", "typst-assets", "typst-library", "typst-macros", "typst-syntax", "typst-timing", "typst-utils", - "xmp-writer", ] [[package]] @@ -3094,6 +3202,7 @@ dependencies = [ name = "typst-syntax" version = "0.13.1" dependencies = [ + "comemo", "ecow", "serde", "toml", @@ -3661,9 +3770,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xmp-writer" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb5954c9ca6dcc869e98d3e42760ed9dab08f3e70212b31d7ab8ae7f3b7a487" +checksum = "ce9e2f4a404d9ebffc0a9832cf4f50907220ba3d7fffa9099261a5cab52f2dd7" [[package]] name = "xz2" @@ -3701,7 +3810,19 @@ checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", - "yoke-derive", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.8.0", "zerofrom", ] @@ -3717,6 +3838,18 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -3778,7 +3911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "serde", - "yoke", + "yoke 0.7.5", "zerofrom", "zerovec-derive", ] @@ -3796,21 +3929,25 @@ dependencies = [ [[package]] name = "zip" -version = "2.2.2" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" dependencies = [ "arbitrary", "crc32fast", "crossbeam-utils", - "displaydoc", "flate2", "indexmap 2.7.1", "memchr", - "thiserror 2.0.11", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b20717f0917c908dc63de2e44e97f1e6b126ca58d0e391cee86d504eb8fbd05" + [[package]] name = "zopfli" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 4e0d3a26c..b548245fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,8 +32,8 @@ typst-svg = { path = "crates/typst-svg", version = "0.13.1" } typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" } typst-timing = { path = "crates/typst-timing", version = "0.13.1" } typst-utils = { path = "crates/typst-utils", version = "0.13.1" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "ab1295f" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "9879589" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "c74e539" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "fddbf8b" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" @@ -59,6 +59,7 @@ fastrand = "2.3" flate2 = "1" fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" +glidesort = "0.1.2" hayagriva = "0.8.1" heck = "0.5" hypher = "0.1.4" @@ -68,24 +69,25 @@ icu_provider_adapters = "1.4" icu_provider_blob = "1.4" icu_segmenter = { version = "1.4", features = ["serde"] } if_chain = "1" -image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif"] } +image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif", "webp"] } indexmap = { version = "2", features = ["serde"] } +infer = { version = "0.19.0", default-features = false } kamadak-exif = "0.6" +krilla = { version = "0.4.0", default-features = false, features = ["raster-images", "comemo", "rayon"] } +krilla-svg = "0.1.0" kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" memchr = "2" -miniz_oxide = "0.8" native-tls = "0.2" notify = "8" once_cell = "1" open = "5.0.1" -openssl = "0.10" +openssl = "0.10.72" oxipng = { version = "9.0", default-features = false, features = ["filetime", "parallel", "zopfli"] } palette = { version = "0.7.3", default-features = false, features = ["approx", "libm"] } parking_lot = "0.12.1" pathdiff = "0.2" -pdf-writer = "0.12.1" phf = { version = "0.11", features = ["macros"] } pixglyph = "0.6" png = "0.17" @@ -112,8 +114,6 @@ sigpipe = "0.1" siphasher = "1" smallvec = { version = "1.11.1", features = ["union", "const_generics", "const_new"] } stacker = "0.1.15" -subsetter = "0.2" -svg2pdf = "0.13" syn = { version = "2", features = ["full", "extra-traits"] } syntect = { version = "5", default-features = false, features = ["parsing", "regex-fancy", "plist-load", "yaml-load"] } tar = "0.4" @@ -135,15 +135,15 @@ unicode-segmentation = "1" unscanny = "0.1" ureq = { version = "2", default-features = false, features = ["native-tls", "gzip", "json"] } usvg = { version = "0.45", default-features = false, features = ["text"] } +utf8_iter = "1.0.4" walkdir = "2" wasmi = "0.40.0" web-sys = "0.3" xmlparser = "0.13.5" xmlwriter = "0.1.0" -xmp-writer = "0.3.1" xz2 = { version = "0.1", features = ["static"] } yaml-front-matter = "0.1" -zip = { version = "2", default-features = false, features = ["deflate"] } +zip = { version = "2.5", default-features = false, features = ["deflate"] } [profile.dev.package."*"] opt-level = 2 diff --git a/README.md b/README.md index 41f465152..9526f3df4 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Typst's CLI is available from different sources: - You can install Typst through different package managers. Note that the versions in the package managers might lag behind the latest release. - - Linux: + - Linux: - View [Typst on Repology][repology] - View [Typst's Snap][snap] - macOS: `brew install typst` @@ -177,22 +177,22 @@ If you prefer an integrated IDE-like experience with autocompletion and instant preview, you can also check out [Typst's free web app][app]. ## Community -The main place where the community gathers is our [Discord server][discord]. -Feel free to join there to ask questions, help out others, share cool things -you created with Typst, or just to chat. +The main places where the community gathers are our [Forum][forum] and our +[Discord server][discord]. The Forum is a great place to ask questions, help +others, and share cool things you created with Typst. The Discord server is more +suitable for quicker questions, discussions about contributing, or just to chat. +We'd be happy to see you there! -Aside from that there are a few places where you can find things built by -the community: - -- The official [package list](https://typst.app/docs/packages) -- The [Awesome Typst](https://github.com/qjcg/awesome-typst) repository +[Typst Universe][universe] is where the community shares templates and packages. +If you want to share your own creations, you can submit them to our +[package repository][packages]. If you had a bad experience in our community, please [reach out to us][contact]. ## Contributing -We would love to see contributions from the community. If you experience bugs, -feel free to open an issue. If you would like to implement a new feature or bug -fix, please follow the steps outlined in the [contribution guide][contributing]. +We love to see contributions from the community. If you experience bugs, feel +free to open an issue. If you would like to implement a new feature or bug fix, +please follow the steps outlined in the [contribution guide][contributing]. To build Typst yourself, first ensure that you have the [latest stable Rust][rust] installed. Then, clone this repository and build the @@ -243,6 +243,8 @@ instant preview. To achieve these goals, we follow three core design principles: [docs]: https://typst.app/docs/ [app]: https://typst.app/ [discord]: https://discord.gg/2uDybryKPe +[forum]: https://forum.typst.app/ +[universe]: https://typst.app/universe/ [tutorial]: https://typst.app/docs/tutorial/ [show]: https://typst.app/docs/reference/styling/#show-rules [math]: https://typst.app/docs/reference/math/ diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index d6855d100..fd0eb5f05 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -361,7 +361,7 @@ pub struct FontArgs { /// Ensures system fonts won't be searched, unless explicitly included via /// `--font-path`. - #[arg(long)] + #[arg(long, env = "TYPST_IGNORE_SYSTEM_FONTS")] pub ignore_system_fonts: bool, } @@ -467,15 +467,45 @@ display_possible_values!(Feature); #[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] #[allow(non_camel_case_types)] pub enum PdfStandard { + /// PDF 1.4. + #[value(name = "1.4")] + V_1_4, + /// PDF 1.5. + #[value(name = "1.5")] + V_1_5, + /// PDF 1.5. + #[value(name = "1.6")] + V_1_6, /// PDF 1.7. #[value(name = "1.7")] V_1_7, + /// PDF 2.0. + #[value(name = "2.0")] + V_2_0, + /// PDF/A-1b. + #[value(name = "a-1b")] + A_1b, /// PDF/A-2b. #[value(name = "a-2b")] A_2b, - /// PDF/A-3b. + /// PDF/A-2u. + #[value(name = "a-2u")] + A_2u, + /// PDF/A-3u. #[value(name = "a-3b")] A_3b, + /// PDF/A-3u. + #[value(name = "a-3u")] + A_3u, + /// PDF/A-4. + #[value(name = "a-4")] + A_4, + /// PDF/A-4f. + #[value(name = "a-4f")] + A_4f, + /// PDF/A-4e. + #[value(name = "a-4e")] + A_4e, } display_possible_values!(PdfStandard); diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index ae71e298c..207bb7d09 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -16,7 +16,7 @@ use typst::diag::{ use typst::foundations::{Datetime, Smart}; use typst::html::HtmlDocument; use typst::layout::{Frame, Page, PageRanges, PagedDocument}; -use typst::syntax::{FileId, Source, Span}; +use typst::syntax::{FileId, Lines, Span}; use typst::WorldExt; use typst_pdf::{PdfOptions, PdfStandards, Timestamp}; @@ -63,8 +63,7 @@ pub struct CompileConfig { /// Opens the output file with the default viewer or a specific program after /// compilation. pub open: Option>, - /// One (or multiple comma-separated) PDF standards that Typst will enforce - /// conformance with. + /// A list of standards the PDF should conform to. pub pdf_standards: PdfStandards, /// A path to write a Makefile rule describing the current compilation. pub make_deps: Option, @@ -130,18 +129,9 @@ impl CompileConfig { PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect()) }); - let pdf_standards = { - let list = args - .pdf_standard - .iter() - .map(|standard| match standard { - PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, - PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, - PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, - }) - .collect::>(); - PdfStandards::new(&list)? - }; + let pdf_standards = PdfStandards::new( + &args.pdf_standard.iter().copied().map(Into::into).collect::>(), + )?; #[cfg(feature = "http-server")] let server = match watch { @@ -295,6 +285,7 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult< }) } }; + let options = PdfOptions { ident: Smart::Auto, timestamp, @@ -705,7 +696,7 @@ fn label(world: &SystemWorld, span: Span) -> Option> { impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { type FileId = FileId; type Name = String; - type Source = Source; + type Source = Lines; fn name(&'a self, id: FileId) -> CodespanResult { let vpath = id.vpath(); @@ -765,3 +756,23 @@ impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { }) } } + +impl From for typst_pdf::PdfStandard { + fn from(standard: PdfStandard) -> Self { + match standard { + PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4, + PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5, + PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6, + PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, + PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0, + PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b, + PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, + PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u, + PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, + PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u, + PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, + PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, + PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, + } + } +} diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index 610f23cd4..7806e456f 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -2,6 +2,7 @@ use comemo::Track; use ecow::{eco_format, EcoString}; use serde::Serialize; use typst::diag::{bail, HintedStrResult, StrResult, Warned}; +use typst::engine::Sink; use typst::foundations::{Content, IntoValue, LocatableSelector, Scope}; use typst::layout::PagedDocument; use typst::syntax::Span; @@ -58,6 +59,8 @@ fn retrieve( let selector = eval_string( &typst::ROUTINES, world.track(), + // TODO: propagate warnings + Sink::new().track_mut(), &command.selector, Span::detached(), EvalMode::Code, diff --git a/crates/typst-cli/src/timings.rs b/crates/typst-cli/src/timings.rs index 9f017dc12..3d10bbc67 100644 --- a/crates/typst-cli/src/timings.rs +++ b/crates/typst-cli/src/timings.rs @@ -85,6 +85,6 @@ fn resolve_span(world: &SystemWorld, span: Span) -> Option<(String, u32)> { let id = span.id()?; let source = world.source(id).ok()?; let range = source.range(span)?; - let line = source.byte_to_line(range.start)?; + let line = source.lines().byte_to_line(range.start)?; Some((format!("{id:?}"), line as u32 + 1)) } diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 12e80d273..95bee235c 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -9,7 +9,7 @@ use ecow::{eco_format, EcoString}; use parking_lot::Mutex; use typst::diag::{FileError, FileResult}; use typst::foundations::{Bytes, Datetime, Dict, IntoValue}; -use typst::syntax::{FileId, Source, VirtualPath}; +use typst::syntax::{FileId, Lines, Source, VirtualPath}; use typst::text::{Font, FontBook}; use typst::utils::LazyHash; use typst::{Library, World}; @@ -181,10 +181,20 @@ impl SystemWorld { } } - /// Lookup a source file by id. + /// Lookup line metadata for a file by id. #[track_caller] - pub fn lookup(&self, id: FileId) -> Source { - self.source(id).expect("file id does not point to any source file") + pub fn lookup(&self, id: FileId) -> Lines { + self.slot(id, |slot| { + if let Some(source) = slot.source.get() { + let source = source.as_ref().expect("file is not valid"); + source.lines().clone() + } else if let Some(bytes) = slot.file.get() { + let bytes = bytes.as_ref().expect("file is not valid"); + Lines::try_from(bytes).expect("file is not valid utf-8") + } else { + panic!("file id does not point to any source file"); + } + }) } } @@ -210,7 +220,9 @@ impl World for SystemWorld { } fn font(&self, index: usize) -> Option { - self.fonts[index].get() + // comemo's validation may invoke this function with an invalid index. This is + // impossible in typst-cli but possible if a custom tool mutates the fonts. + self.fonts.get(index)?.get() } fn today(&self, offset: Option) -> Option { @@ -337,6 +349,11 @@ impl SlotCell { self.accessed = false; } + /// Gets the contents of the cell. + fn get(&self) -> Option<&FileResult> { + self.data.as_ref() + } + /// Gets the contents of the cell or initialize them. fn get_or_init( &mut self, diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index 1ca7b4b8f..eaeabbab3 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -25,19 +25,22 @@ impl Eval for ast::FuncCall<'_> { fn eval(self, vm: &mut Vm) -> SourceResult { let span = self.span(); let callee = self.callee(); - let in_math = in_math(callee); let callee_span = callee.span(); let args = self.args(); - let trailing_comma = args.trailing_comma(); vm.engine.route.check_call_depth().at(span)?; // Try to evaluate as a call to an associated function or field. - let (callee, args) = if let ast::Expr::FieldAccess(access) = callee { + let (callee_value, args_value) = if let ast::Expr::FieldAccess(access) = callee { let target = access.target(); let field = access.field(); match eval_field_call(target, field, args, span, vm)? { - FieldCall::Normal(callee, args) => (callee, args), + FieldCall::Normal(callee, args) => { + if vm.inspected == Some(callee_span) { + vm.trace(callee.clone()); + } + (callee, args) + } FieldCall::Resolved(value) => return Ok(value), } } else { @@ -45,9 +48,15 @@ impl Eval for ast::FuncCall<'_> { (callee.eval(vm)?, args.eval(vm)?.spanned(span)) }; - let func_result = callee.clone().cast::(); - if in_math && func_result.is_err() { - return wrap_args_in_math(callee, callee_span, args, trailing_comma); + let func_result = callee_value.clone().cast::(); + + if func_result.is_err() && in_math(callee) { + return wrap_args_in_math( + callee_value, + callee_span, + args_value, + args.trailing_comma(), + ); } let func = func_result @@ -56,8 +65,11 @@ impl Eval for ast::FuncCall<'_> { let point = || Tracepoint::Call(func.name().map(Into::into)); let f = || { - func.call(&mut vm.engine, vm.context, args) - .trace(vm.world(), point, span) + func.call(&mut vm.engine, vm.context, args_value).trace( + vm.world(), + point, + span, + ) }; // Stacker is broken on WASM. @@ -404,12 +416,14 @@ fn wrap_args_in_math( if trailing_comma { body += SymbolElem::packed(','); } - Ok(Value::Content( - callee.display().spanned(callee_span) - + LrElem::new(SymbolElem::packed('(') + body + SymbolElem::packed(')')) - .pack() - .spanned(args.span), - )) + + let formatted = callee.display().spanned(callee_span) + + LrElem::new(SymbolElem::packed('(') + body + SymbolElem::packed(')')) + .pack() + .spanned(args.span); + + args.finish()?; + Ok(Value::Content(formatted)) } /// Provide a hint if the callee is a shadowed standard library function. diff --git a/crates/typst-eval/src/lib.rs b/crates/typst-eval/src/lib.rs index 5eae7c1df..586da26be 100644 --- a/crates/typst-eval/src/lib.rs +++ b/crates/typst-eval/src/lib.rs @@ -101,6 +101,7 @@ pub fn eval( pub fn eval_string( routines: &Routines, world: Tracked, + sink: TrackedMut, string: &str, span: Span, mode: EvalMode, @@ -121,7 +122,6 @@ pub fn eval_string( } // Prepare the engine. - let mut sink = Sink::new(); let introspector = Introspector::default(); let traced = Traced::default(); let engine = Engine { @@ -129,7 +129,7 @@ pub fn eval_string( world, introspector: introspector.track(), traced: traced.track(), - sink: sink.track_mut(), + sink, route: Route::default(), }; diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index aa769976e..7d78a5da4 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -263,13 +263,13 @@ fn handle( /// Wrap the nodes in `` and `` if they are not yet rooted, /// supplying a suitable ``. fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { + let head = head_element(info); let body = match classify_output(output)? { OutputKind::Html(element) => return Ok(element), OutputKind::Body(body) => body, OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs), }; - Ok(HtmlElement::new(tag::html) - .with_children(vec![head_element(info).into(), body.into()])) + Ok(HtmlElement::new(tag::html).with_children(vec![head.into(), body.into()])) } /// Generate a `` element. @@ -302,6 +302,24 @@ fn head_element(info: &DocumentInfo) -> HtmlElement { ); } + if !info.author.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "authors") + .with_attr(attr::content, info.author.join(", ")) + .into(), + ) + } + + if !info.keywords.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "keywords") + .with_attr(attr::content, info.keywords.join(", ")) + .into(), + ) + } + HtmlElement::new(tag::head).with_children(children) } diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 91fa53f9a..c98320679 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -15,7 +15,7 @@ use typst::syntax::{ ast, is_id_continue, is_id_start, is_ident, FileId, LinkedNode, Side, Source, SyntaxKind, }; -use typst::text::RawElem; +use typst::text::{FontFlags, RawElem}; use typst::visualize::Color; use unscanny::Scanner; @@ -298,13 +298,20 @@ fn complete_math(ctx: &mut CompletionContext) -> bool { return false; } - // Start of an interpolated identifier: "#|". + // Start of an interpolated identifier: "$#|$". if ctx.leaf.kind() == SyntaxKind::Hash { ctx.from = ctx.cursor; code_completions(ctx, true); return true; } + // Behind existing interpolated identifier: "$#pa|$". + if ctx.leaf.kind() == SyntaxKind::Ident { + ctx.from = ctx.leaf.offset(); + code_completions(ctx, true); + return true; + } + // Behind existing atom or identifier: "$a|$" or "$abc|$". if matches!( ctx.leaf.kind(), @@ -841,7 +848,9 @@ fn param_value_completions<'a>( /// Returns which file extensions to complete for the given parameter if any. fn path_completion(func: &Func, param: &ParamInfo) -> Option<&'static [&'static str]> { Some(match (func.name(), param.name) { - (Some("image"), "source") => &["png", "jpg", "jpeg", "gif", "svg", "svgz"], + (Some("image"), "source") => { + &["png", "jpg", "jpeg", "gif", "svg", "svgz", "webp"] + } (Some("csv"), "source") => &["csv"], (Some("plugin"), "source") => &["wasm"], (Some("cbor"), "source") => &["cbor"], @@ -1081,6 +1090,24 @@ fn code_completions(ctx: &mut CompletionContext, hash: bool) { } } +/// See if the AST node is somewhere within a show rule applying to equations +fn is_in_equation_show_rule(leaf: &LinkedNode<'_>) -> bool { + let mut node = leaf; + while let Some(parent) = node.parent() { + if_chain! { + if let Some(expr) = parent.get().cast::(); + if let ast::Expr::ShowRule(show) = expr; + if let Some(ast::Expr::FieldAccess(field)) = show.selector(); + if field.field().as_str() == "equation"; + then { + return true; + } + } + node = parent; + } + false +} + /// Context for autocompletion. struct CompletionContext<'a> { world: &'a (dyn IdeWorld + 'a), @@ -1152,10 +1179,12 @@ impl<'a> CompletionContext<'a> { /// Add completions for all font families. fn font_completions(&mut self) { - let equation = self.before_window(25).contains("equation"); + let equation = is_in_equation_show_rule(self.leaf); for (family, iter) in self.world.book().families() { - let detail = summarize_font_family(iter); - if !equation || family.contains("Math") { + let variants: Vec<_> = iter.collect(); + let is_math = variants.iter().any(|f| f.flags.contains(FontFlags::MATH)); + let detail = summarize_font_family(variants); + if !equation || is_math { self.str_completion( family, Some(CompletionKind::Font), @@ -1644,6 +1673,13 @@ mod tests { test("#{() .a}", -2).must_include(["at", "any", "all"]); } + /// Test that autocomplete in math uses the correct global scope. + #[test] + fn test_autocomplete_math_scope() { + test("$#col$", -2).must_include(["colbreak"]).must_exclude(["colon"]); + test("$col$", -2).must_include(["colon"]).must_exclude(["colbreak"]); + } + /// Test that the `before_window` doesn't slice into invalid byte /// boundaries. #[test] @@ -1662,7 +1698,7 @@ mod tests { // Then, add the invalid `#cite` call. Had the document been invalid // initially, we would have no populated document to autocomplete with. - let end = world.main.len_bytes(); + let end = world.main.text().len(); world.main.edit(end..end, " #cite()"); test_with_doc(&world, -2, doc.as_ref()) @@ -1790,4 +1826,21 @@ mod tests { .must_include(["r", "dashed"]) .must_exclude(["cases"]); } + + #[test] + fn test_autocomplete_fonts() { + test("#text(font:)", -1) + .must_include(["\"Libertinus Serif\"", "\"New Computer Modern Math\""]); + + test("#show link: set text(font: )", -1) + .must_include(["\"Libertinus Serif\"", "\"New Computer Modern Math\""]); + + test("#show math.equation: set text(font: )", -1) + .must_include(["\"New Computer Modern Math\""]) + .must_exclude(["\"Libertinus Serif\""]); + + test("#show math.equation: it => { set text(font: )\nit }", -6) + .must_include(["\"New Computer Modern Math\""]) + .must_exclude(["\"Libertinus Serif\""]); + } } diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs index 428335426..b29bc4a48 100644 --- a/crates/typst-ide/src/jump.rs +++ b/crates/typst-ide/src/jump.rs @@ -3,7 +3,7 @@ use std::num::NonZeroUsize; use typst::layout::{Frame, FrameItem, PagedDocument, Point, Position, Size}; use typst::model::{Destination, Url}; use typst::syntax::{FileId, LinkedNode, Side, Source, Span, SyntaxKind}; -use typst::visualize::Geometry; +use typst::visualize::{Curve, CurveItem, FillRule, Geometry}; use typst::WorldExt; use crate::IdeWorld; @@ -53,10 +53,20 @@ pub fn jump_from_click( for (mut pos, item) in frame.items().rev() { match item { FrameItem::Group(group) => { - // TODO: Handle transformation. - if let Some(span) = - jump_from_click(world, document, &group.frame, click - pos) - { + let pos = click - pos; + if let Some(clip) = &group.clip { + if !clip.contains(FillRule::NonZero, pos) { + continue; + } + } + // Realistic transforms should always be invertible. + // An example of one that isn't is a scale of 0, which would + // not be clickable anyway. + let Some(inv_transform) = group.transform.invert() else { + continue; + }; + let pos = pos.transform_inf(inv_transform); + if let Some(span) = jump_from_click(world, document, &group.frame, pos) { return Some(span); } } @@ -94,9 +104,32 @@ pub fn jump_from_click( } FrameItem::Shape(shape, span) => { - let Geometry::Rect(size) = shape.geometry else { continue }; - if is_in_rect(pos, size, click) { - return Jump::from_span(world, *span); + if shape.fill.is_some() { + let within = match &shape.geometry { + Geometry::Line(..) => false, + Geometry::Rect(size) => is_in_rect(pos, *size, click), + Geometry::Curve(curve) => { + curve.contains(shape.fill_rule, click - pos) + } + }; + if within { + return Jump::from_span(world, *span); + } + } + + if let Some(stroke) = &shape.stroke { + let within = !stroke.thickness.approx_empty() && { + // This curve is rooted at (0, 0), not `pos`. + let base_curve = match &shape.geometry { + Geometry::Line(to) => &Curve(vec![CurveItem::Line(*to)]), + Geometry::Rect(size) => &Curve::rect(*size), + Geometry::Curve(curve) => curve, + }; + base_curve.stroke_contains(stroke, click - pos) + }; + if within { + return Jump::from_span(world, *span); + } } } @@ -146,9 +179,8 @@ pub fn jump_from_cursor( fn find_in_frame(frame: &Frame, span: Span) -> Option { for (mut pos, item) in frame.items() { if let FrameItem::Group(group) = item { - // TODO: Handle transformation. if let Some(point) = find_in_frame(&group.frame, span) { - return Some(point + pos); + return Some(pos + point.transform(group.transform)); } } @@ -269,6 +301,97 @@ mod tests { test_click("$a + b$", point(28.0, 14.0), cursor(5)); } + #[test] + fn test_jump_from_click_transform_clip() { + let margin = point(10.0, 10.0); + test_click( + "#rect(width: 20pt, height: 20pt, fill: black)", + point(10.0, 10.0) + margin, + cursor(1), + ); + test_click( + "#rect(width: 60pt, height: 10pt, fill: black)", + point(5.0, 30.0) + margin, + None, + ); + test_click( + "#rotate(90deg, origin: bottom + left, rect(width: 60pt, height: 10pt, fill: black))", + point(5.0, 30.0) + margin, + cursor(38), + ); + test_click( + "#scale(x: 300%, y: 300%, origin: top + left, rect(width: 10pt, height: 10pt, fill: black))", + point(20.0, 20.0) + margin, + cursor(45), + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: true, scale(x: 300%, y: 300%, \ + origin: top + left, rect(width: 10pt, height: 10pt, fill: black)))", + point(20.0, 20.0) + margin, + None, + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: false, rect(width: 30pt, height: 30pt, fill: black))", + point(20.0, 20.0) + margin, + cursor(45), + ); + test_click( + "#box(width: 10pt, height: 10pt, clip: true, rect(width: 30pt, height: 30pt, fill: black))", + point(20.0, 20.0) + margin, + None, + ); + test_click( + "#rotate(90deg, origin: bottom + left)[hello world]", + point(5.0, 15.0) + margin, + cursor(40), + ); + } + + #[test] + fn test_jump_from_click_shapes() { + let margin = point(10.0, 10.0); + + test_click( + "#rect(width: 30pt, height: 30pt, fill: black)", + point(15.0, 15.0) + margin, + cursor(1), + ); + + let circle = "#circle(width: 30pt, height: 30pt, fill: black)"; + test_click(circle, point(15.0, 15.0) + margin, cursor(1)); + test_click(circle, point(1.0, 1.0) + margin, None); + + let bowtie = + "#polygon(fill: black, (0pt, 0pt), (20pt, 20pt), (20pt, 0pt), (0pt, 20pt))"; + test_click(bowtie, point(1.0, 2.0) + margin, cursor(1)); + test_click(bowtie, point(2.0, 1.0) + margin, None); + test_click(bowtie, point(19.0, 10.0) + margin, cursor(1)); + + let evenodd = r#"#polygon(fill: black, fill-rule: "even-odd", + (0pt, 10pt), (30pt, 10pt), (30pt, 20pt), (20pt, 20pt), + (20pt, 0pt), (10pt, 0pt), (10pt, 30pt), (20pt, 30pt), + (20pt, 20pt), (0pt, 20pt))"#; + test_click(evenodd, point(15.0, 15.0) + margin, None); + test_click(evenodd, point(5.0, 15.0) + margin, cursor(1)); + test_click(evenodd, point(15.0, 5.0) + margin, cursor(1)); + } + + #[test] + fn test_jump_from_click_shapes_stroke() { + let margin = point(10.0, 10.0); + + let rect = + "#place(dx: 10pt, dy: 10pt, rect(width: 10pt, height: 10pt, stroke: 5pt))"; + test_click(rect, point(15.0, 15.0) + margin, None); + test_click(rect, point(10.0, 15.0) + margin, cursor(27)); + + test_click( + "#line(angle: 45deg, length: 10pt, stroke: 2pt)", + point(2.0, 2.0) + margin, + cursor(1), + ); + } + #[test] fn test_jump_from_cursor() { let s = "*Hello* #box[ABC] World"; @@ -281,6 +404,15 @@ mod tests { test_cursor("$a + b$", -3, pos(1, 27.51, 16.83)); } + #[test] + fn test_jump_from_cursor_transform() { + test_cursor( + r#"#rotate(90deg, origin: bottom + left, [hello world])"#, + -5, + pos(1, 10.0, 16.58), + ); + } + #[test] fn test_backlink() { let s = "#footnote[Hi]"; diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index 6678ab841..1176e4601 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -97,7 +97,7 @@ impl World for TestWorld { } fn font(&self, index: usize) -> Option { - Some(self.base.fonts[index].clone()) + self.base.fonts.get(index).cloned() } fn today(&self, _: Option) -> Option { @@ -228,7 +228,7 @@ impl FilePos for (&str, isize) { #[track_caller] fn cursor(source: &Source, cursor: isize) -> usize { if cursor < 0 { - source.len_bytes().checked_add_signed(cursor + 1).unwrap() + source.text().len().checked_add_signed(cursor + 1).unwrap() } else { cursor as usize } diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index cbfffe530..528f679cf 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -86,7 +86,7 @@ fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { *count += 1; continue; } else if *count > 1 { - write!(pieces.last_mut().unwrap(), " (x{count})").unwrap(); + write!(pieces.last_mut().unwrap(), " (×{count})").unwrap(); } } pieces.push(value.repr()); @@ -95,7 +95,7 @@ fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { if let Some((_, count)) = last { if count > 1 { - write!(pieces.last_mut().unwrap(), " (x{count})").unwrap(); + write!(pieces.last_mut().unwrap(), " (×{count})").unwrap(); } } @@ -269,7 +269,7 @@ fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { .find(|&(family, _)| family.to_lowercase().as_str() == lower.as_str()); then { - let detail = summarize_font_family(iter); + let detail = summarize_font_family(iter.collect()); return Some(Tooltip::Text(detail)); } }; @@ -371,4 +371,11 @@ mod tests { test(&world, -2, Side::Before).must_be_none(); test(&world, -2, Side::After).must_be_text("This star imports `a`, `b`, and `c`"); } + + #[test] + fn test_tooltip_field_call() { + let world = TestWorld::new("#import \"other.typ\"\n#other.f()") + .with_source("other.typ", "#let f = (x) => 1"); + test(&world, -4, Side::After).must_be_code("(..) => .."); + } } diff --git a/crates/typst-ide/src/utils.rs b/crates/typst-ide/src/utils.rs index d5d584e2b..13de402ba 100644 --- a/crates/typst-ide/src/utils.rs +++ b/crates/typst-ide/src/utils.rs @@ -77,23 +77,20 @@ pub fn plain_docs_sentence(docs: &str) -> EcoString { } /// Create a short description of a font family. -pub fn summarize_font_family<'a>( - variants: impl Iterator, -) -> EcoString { - let mut infos: Vec<_> = variants.collect(); - infos.sort_by_key(|info| info.variant); +pub fn summarize_font_family(mut variants: Vec<&FontInfo>) -> EcoString { + variants.sort_by_key(|info| info.variant); let mut has_italic = false; let mut min_weight = u16::MAX; let mut max_weight = 0; - for info in &infos { + for info in &variants { let weight = info.variant.weight.to_number(); has_italic |= info.variant.style == FontStyle::Italic; min_weight = min_weight.min(weight); max_weight = min_weight.max(weight); } - let count = infos.len(); + let count = variants.len(); let mut detail = eco_format!("{count} variant{}.", if count == 1 { "" } else { "s" }); if min_weight == max_weight { @@ -117,7 +114,9 @@ pub fn globals<'a>(world: &'a dyn IdeWorld, leaf: &LinkedNode) -> &'a Scope { | Some(SyntaxKind::Math) | Some(SyntaxKind::MathFrac) | Some(SyntaxKind::MathAttach) - ); + ) && leaf + .prev_leaf() + .is_none_or(|prev| !matches!(prev.kind(), SyntaxKind::Hash)); let library = world.library(); if in_math { diff --git a/crates/typst-kit/src/download.rs b/crates/typst-kit/src/download.rs index 40084e51b..a4d49b4f3 100644 --- a/crates/typst-kit/src/download.rs +++ b/crates/typst-kit/src/download.rs @@ -128,8 +128,7 @@ impl Downloader { } // Configure native TLS. - let connector = - tls.build().map_err(|err| io::Error::new(io::ErrorKind::Other, err))?; + let connector = tls.build().map_err(io::Error::other)?; builder = builder.tls_connector(Arc::new(connector)); builder.build().get(url).call() diff --git a/crates/typst-layout/Cargo.toml b/crates/typst-layout/Cargo.toml index 438e09e43..cc355a3db 100644 --- a/crates/typst-layout/Cargo.toml +++ b/crates/typst-layout/Cargo.toml @@ -30,6 +30,7 @@ icu_provider_adapters = { workspace = true } icu_provider_blob = { workspace = true } icu_segmenter = { workspace = true } kurbo = { workspace = true } +memchr = { workspace = true } rustybuzz = { workspace = true } smallvec = { workspace = true } ttf-parser = { workspace = true } diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index dc9e2238d..42fe38dbe 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -3,7 +3,9 @@ use std::fmt::Debug; use typst_library::diag::{bail, SourceResult}; use typst_library::engine::Engine; use typst_library::foundations::{Resolve, StyleChain}; -use typst_library::layout::grid::resolve::{Cell, CellGrid, LinePosition, Repeatable}; +use typst_library::layout::grid::resolve::{ + Cell, CellGrid, Header, LinePosition, Repeatable, +}; use typst_library::layout::{ Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel, Size, Sizing, @@ -11,7 +13,7 @@ use typst_library::layout::{ use typst_library::text::TextElem; use typst_library::visualize::Geometry; use typst_syntax::Span; -use typst_utils::{MaybeReverseIter, Numeric}; +use typst_utils::Numeric; use super::{ generate_line_segments, hline_stroke_at_column, layout_cell, vline_stroke_at_row, @@ -30,10 +32,8 @@ pub struct GridLayouter<'a> { pub(super) rcols: Vec, /// The sum of `rcols`. pub(super) width: Abs, - /// Resolve row sizes, by region. + /// Resolved row sizes, by region. pub(super) rrows: Vec>, - /// Rows in the current region. - pub(super) lrows: Vec, /// The amount of unbreakable rows remaining to be laid out in the /// current unbreakable row group. While this is positive, no region breaks /// should occur. @@ -41,24 +41,155 @@ pub struct GridLayouter<'a> { /// Rowspans not yet laid out because not all of their spanned rows were /// laid out yet. pub(super) rowspans: Vec, - /// The initial size of the current region before we started subtracting. - pub(super) initial: Size, + /// Grid layout state for the current region. + pub(super) current: Current, /// Frames for finished regions. pub(super) finished: Vec, + /// The amount and height of header rows on each finished region. + pub(super) finished_header_rows: Vec, /// Whether this is an RTL grid. pub(super) is_rtl: bool, - /// The simulated header height. - /// This field is reset in `layout_header` and properly updated by + /// Currently repeating headers, one per level. Sorted by increasing + /// levels. + /// + /// Note that some levels may be absent, in particular level 0, which does + /// not exist (so all levels are >= 1). + pub(super) repeating_headers: Vec<&'a Header>, + /// Headers, repeating or not, awaiting their first successful layout. + /// Sorted by increasing levels. + pub(super) pending_headers: &'a [Repeatable
], + /// Next headers to be processed. + pub(super) upcoming_headers: &'a [Repeatable
], + /// State of the row being currently laid out. + /// + /// This is kept as a field to avoid passing down too many parameters from + /// `layout_row` into called functions, which would then have to pass them + /// down to `push_row`, which reads these values. + pub(super) row_state: RowState, + /// The span of the grid element. + pub(super) span: Span, +} + +/// Grid layout state for the current region. This should be reset or updated +/// on each region break. +pub(super) struct Current { + /// The initial size of the current region before we started subtracting. + pub(super) initial: Size, + /// The height of the region after repeated headers were placed and footers + /// prepared. This also includes pending repeating headers from the start, + /// even if they were not repeated yet, since they will be repeated in the + /// next region anyway (bar orphan prevention). + /// + /// This is used to quickly tell if any additional space in the region has + /// been occupied since then, meaning that additional space will become + /// available after a region break (see + /// [`GridLayouter::may_progress_with_repeats`]). + pub(super) initial_after_repeats: Abs, + /// Whether `layouter.regions.may_progress()` was `true` at the top of the + /// region. + pub(super) could_progress_at_top: bool, + /// Rows in the current region. + pub(super) lrows: Vec, + /// The amount of repeated header rows at the start of the current region. + /// Thus, excludes rows from pending headers (which were placed for the + /// first time). + /// + /// Note that `repeating_headers` and `pending_headers` can change if we + /// find a new header inside the region (not at the top), so this field + /// is required to access information from the top of the region. + /// + /// This information is used on finish region to calculate the total height + /// of resolved header rows at the top of the region, which is used by + /// multi-page rowspans so they can properly skip the header rows at the + /// top of each region during layout. + pub(super) repeated_header_rows: usize, + /// The end bound of the row range of the last repeating header at the + /// start of the region. + /// + /// The last row might have disappeared from layout due to being empty, so + /// this is how we can become aware of where the last header ends without + /// having to check the vector of rows. Line layout uses this to determine + /// when to prioritize the last lines under a header. + /// + /// A value of zero indicates no repeated headers were placed. + pub(super) last_repeated_header_end: usize, + /// Stores the length of `lrows` before a sequence of rows equipped with + /// orphan prevention was laid out. In this case, if no more rows without + /// orphan prevention are laid out after those rows before the region ends, + /// the rows will be removed, and there may be an attempt to place them + /// again in the new region. Effectively, this is the mechanism used for + /// orphan prevention of rows. + /// + /// At the moment, this is only used by repeated headers (they aren't laid + /// out if alone in the region) and by new headers, which are moved to the + /// `pending_headers` vector and so will automatically be placed again + /// until they fit and are not orphans in at least one region (or exactly + /// one, for non-repeated headers). + pub(super) lrows_orphan_snapshot: Option, + /// The height of effectively repeating headers, that is, ignoring + /// non-repeating pending headers, in the current region. + /// + /// This is used by multi-page auto rows so they can inform cell layout on + /// how much space should be taken by headers if they break across regions. + /// In particular, non-repeating headers only occupy the initial region, + /// but disappear on new regions, so they can be ignored. + /// + /// This field is reset on each new region and properly updated by /// `layout_auto_row` and `layout_relative_row`, and should not be read /// before all header rows are fully laid out. It is usually fine because /// header rows themselves are unbreakable, and unbreakable rows do not /// need to read this field at all. - pub(super) header_height: Abs, + /// + /// This height is not only computed at the beginning of the region. It is + /// updated whenever a new header is found, subtracting the height of + /// headers which stopped repeating and adding the height of all new + /// headers. + pub(super) repeating_header_height: Abs, + /// The height for each repeating header that was placed in this region. + /// Note that this includes headers not at the top of the region, before + /// their first repetition (pending headers), and excludes headers removed + /// by virtue of a new, conflicting header being found (short-lived + /// headers). + /// + /// This is used to know how much to update `repeating_header_height` by + /// when finding a new header and causing existing repeating headers to + /// stop. + pub(super) repeating_header_heights: Vec, /// The simulated footer height for this region. + /// /// The simulation occurs before any rows are laid out for a region. pub(super) footer_height: Abs, - /// The span of the grid element. - pub(super) span: Span, +} + +/// Data about the row being laid out right now. +#[derive(Debug, Default)] +pub(super) struct RowState { + /// If this is `Some`, this will be updated by the currently laid out row's + /// height if it is auto or relative. This is used for header height + /// calculation. + pub(super) current_row_height: Option, + /// This is `true` when laying out non-short lived headers and footers. + /// That is, headers and footers which are not immediately followed or + /// preceded (respectively) by conflicting headers and footers of same or + /// lower level, or the end or start of the table (respectively), which + /// would cause them to never repeat, even once. + /// + /// If this is `false`, the next row to be laid out will remove an active + /// orphan snapshot and will flush pending headers, as there is no risk + /// that they will be orphans anymore. + pub(super) in_active_repeatable: bool, +} + +/// Data about laid out repeated header rows for a specific finished region. +#[derive(Debug, Default)] +pub(super) struct FinishedHeaderRowInfo { + /// The amount of repeated headers at the top of the region. + pub(super) repeated_amount: usize, + /// The end bound of the row range of the last repeated header at the top + /// of the region. + pub(super) last_repeated_header_end: usize, + /// The total height of repeated headers at the top of the region. + pub(super) repeated_height: Abs, } /// Details about a resulting row piece. @@ -114,14 +245,27 @@ impl<'a> GridLayouter<'a> { rcols: vec![Abs::zero(); grid.cols.len()], width: Abs::zero(), rrows: vec![], - lrows: vec![], unbreakable_rows_left: 0, rowspans: vec![], - initial: regions.size, finished: vec![], + finished_header_rows: vec![], is_rtl: TextElem::dir_in(styles) == Dir::RTL, - header_height: Abs::zero(), - footer_height: Abs::zero(), + repeating_headers: vec![], + upcoming_headers: &grid.headers, + pending_headers: Default::default(), + row_state: RowState::default(), + current: Current { + initial: regions.size, + initial_after_repeats: regions.size.y, + could_progress_at_top: regions.may_progress(), + lrows: vec![], + repeated_header_rows: 0, + last_repeated_header_end: 0, + lrows_orphan_snapshot: None, + repeating_header_height: Abs::zero(), + repeating_header_heights: vec![], + footer_height: Abs::zero(), + }, span, } } @@ -130,38 +274,57 @@ impl<'a> GridLayouter<'a> { pub fn layout(mut self, engine: &mut Engine) -> SourceResult { self.measure_columns(engine)?; - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - // Ensure rows in the first region will be aware of the possible - // presence of the footer. - self.prepare_footer(footer, engine, 0)?; - if matches!(self.grid.header, None | Some(Repeatable::NotRepeated(_))) { - // No repeatable header, so we won't subtract it later. - self.regions.size.y -= self.footer_height; + if let Some(footer) = &self.grid.footer { + if footer.repeated { + // Ensure rows in the first region will be aware of the + // possible presence of the footer. + self.prepare_footer(footer, engine, 0)?; + self.regions.size.y -= self.current.footer_height; + self.current.initial_after_repeats = self.regions.size.y; } } - for y in 0..self.grid.rows.len() { - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - if y < header.end { - if y == 0 { - self.layout_header(header, engine, 0)?; - self.regions.size.y -= self.footer_height; - } + let mut y = 0; + let mut consecutive_header_count = 0; + while y < self.grid.rows.len() { + if let Some(next_header) = self.upcoming_headers.get(consecutive_header_count) + { + if next_header.range.contains(&y) { + self.place_new_headers(&mut consecutive_header_count, engine)?; + y = next_header.range.end; + // Skip header rows during normal layout. continue; } } - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - if y >= footer.start { + if let Some(footer) = &self.grid.footer { + if footer.repeated && y >= footer.start { if y == footer.start { self.layout_footer(footer, engine, self.finished.len())?; + self.flush_orphans(); } + y = footer.end; continue; } } self.layout_row(y, engine, 0)?; + + // After the first non-header row is placed, pending headers are no + // longer orphans and can repeat, so we move them to repeating + // headers. + // + // Note that this is usually done in `push_row`, since the call to + // `layout_row` above might trigger region breaks (for multi-page + // auto rows), whereas this needs to be called as soon as any part + // of a row is laid out. However, it's possible a row has no + // visible output and thus does not push any rows even though it + // was successfully laid out, in which case we additionally flush + // here just in case. + self.flush_orphans(); + + y += 1; } self.finish_region(engine, true)?; @@ -184,12 +347,46 @@ impl<'a> GridLayouter<'a> { self.render_fills_strokes() } - /// Layout the given row. + /// Layout a row with a certain initial state, returning the final state. + #[inline] + pub(super) fn layout_row_with_state( + &mut self, + y: usize, + engine: &mut Engine, + disambiguator: usize, + initial_state: RowState, + ) -> SourceResult { + // Keep a copy of the previous value in the stack, as this function can + // call itself recursively (e.g. if a region break is triggered and a + // header is placed), so we shouldn't outright overwrite it, but rather + // save and later restore the state when back to this call. + let previous = std::mem::replace(&mut self.row_state, initial_state); + + // Keep it as a separate function to allow inlining the return below, + // as it's usually not needed. + self.layout_row_internal(y, engine, disambiguator)?; + + Ok(std::mem::replace(&mut self.row_state, previous)) + } + + /// Layout the given row with the default row state. + #[inline] pub(super) fn layout_row( &mut self, y: usize, engine: &mut Engine, disambiguator: usize, + ) -> SourceResult<()> { + self.layout_row_with_state(y, engine, disambiguator, RowState::default())?; + Ok(()) + } + + /// Layout the given row using the current state. + pub(super) fn layout_row_internal( + &mut self, + y: usize, + engine: &mut Engine, + disambiguator: usize, ) -> SourceResult<()> { // Skip to next region if current one is full, but only for content // rows, not for gutter rows, and only if we aren't laying out an @@ -206,13 +403,18 @@ impl<'a> GridLayouter<'a> { } // Don't layout gutter rows at the top of a region. - if is_content_row || !self.lrows.is_empty() { + if is_content_row || !self.current.lrows.is_empty() { match self.grid.rows[y] { Sizing::Auto => self.layout_auto_row(engine, disambiguator, y)?, Sizing::Rel(v) => { self.layout_relative_row(engine, disambiguator, v, y)? } - Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y, disambiguator)), + Sizing::Fr(v) => { + if !self.row_state.in_active_repeatable { + self.flush_orphans(); + } + self.current.lrows.push(Row::Fr(v, y, disambiguator)) + } } } @@ -225,8 +427,13 @@ impl<'a> GridLayouter<'a> { fn render_fills_strokes(mut self) -> SourceResult { let mut finished = std::mem::take(&mut self.finished); let frame_amount = finished.len(); - for ((frame_index, frame), rows) in - finished.iter_mut().enumerate().zip(&self.rrows) + for (((frame_index, frame), rows), finished_header_rows) in + finished.iter_mut().enumerate().zip(&self.rrows).zip( + self.finished_header_rows + .iter() + .map(Some) + .chain(std::iter::repeat(None)), + ) { if self.rcols.is_empty() || rows.is_empty() { continue; @@ -347,7 +554,8 @@ impl<'a> GridLayouter<'a> { let hline_indices = rows .iter() .map(|piece| piece.y) - .chain(std::iter::once(self.grid.rows.len())); + .chain(std::iter::once(self.grid.rows.len())) + .enumerate(); // Converts a row to the corresponding index in the vector of // hlines. @@ -372,7 +580,7 @@ impl<'a> GridLayouter<'a> { }; let mut prev_y = None; - for (y, dy) in hline_indices.zip(hline_offsets) { + for ((i, y), dy) in hline_indices.zip(hline_offsets) { // Position of lines below the row index in the previous iteration. let expected_prev_line_position = prev_y .map(|prev_y| { @@ -383,47 +591,40 @@ impl<'a> GridLayouter<'a> { }) .unwrap_or(LinePosition::Before); - // FIXME: In the future, directly specify in 'self.rrows' when - // we place a repeated header rather than its original rows. - // That would let us remove most of those verbose checks, both - // in 'lines.rs' and here. Those checks also aren't fully - // accurate either, since they will also trigger when some rows - // have been removed between the header and what's below it. - let is_under_repeated_header = self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .zip(prev_y) - .is_some_and(|(header, prev_y)| { - // Note: 'y == header.end' would mean we're right below - // the NON-REPEATED header, so that case should return - // false. - prev_y < header.end && y > header.end - }); + // Header's lines at the bottom have priority when repeated. + // This will store the end bound of the last header if the + // current iteration is calculating lines under it. + let last_repeated_header_end_above = match finished_header_rows { + Some(info) if prev_y.is_some() && i == info.repeated_amount => { + Some(info.last_repeated_header_end) + } + _ => None, + }; // If some grid rows were omitted between the previous resolved // row and the current one, we ensure lines below the previous // row don't "disappear" and are considered, albeit with less // priority. However, don't do this when we're below a header, // as it must have more priority instead of less, so it is - // chained later instead of before. The exception is when the + // chained later instead of before (stored in the + // 'header_hlines' variable below). The exception is when the // last row in the header is removed, in which case we append // both the lines under the row above us and also (later) the // lines under the header's (removed) last row. - let prev_lines = prev_y - .filter(|prev_y| { - prev_y + 1 != y - && (!is_under_repeated_header - || self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| prev_y + 1 != header.end)) - }) - .map(|prev_y| get_hlines_at(prev_y + 1)) - .unwrap_or(&[]); + let prev_lines = match prev_y { + Some(prev_y) + if prev_y + 1 != y + && last_repeated_header_end_above.is_none_or( + |last_repeated_header_end| { + prev_y + 1 != last_repeated_header_end + }, + ) => + { + get_hlines_at(prev_y + 1) + } + + _ => &[], + }; let expected_hline_position = expected_line_position(y, y == self.grid.rows.len()); @@ -441,15 +642,13 @@ impl<'a> GridLayouter<'a> { }; let mut expected_header_line_position = LinePosition::Before; - let header_hlines = if let Some((Repeatable::Repeated(header), prev_y)) = - self.grid.header.as_ref().zip(prev_y) - { - if is_under_repeated_header - && (!self.grid.has_gutter + let header_hlines = match (last_repeated_header_end_above, prev_y) { + (Some(header_end_above), Some(prev_y)) + if !self.grid.has_gutter || matches!( self.grid.rows[prev_y], Sizing::Rel(length) if length.is_zero() - )) + ) => { // For lines below a header, give priority to the // lines originally below the header rather than @@ -468,15 +667,13 @@ impl<'a> GridLayouter<'a> { // column-gutter is specified, for example. In that // case, we still repeat the line under the gutter. expected_header_line_position = expected_line_position( - header.end, - header.end == self.grid.rows.len(), + header_end_above, + header_end_above == self.grid.rows.len(), ); - get_hlines_at(header.end) - } else { - &[] + get_hlines_at(header_end_above) } - } else { - &[] + + _ => &[], }; // The effective hlines to be considered at this row index are @@ -529,6 +726,7 @@ impl<'a> GridLayouter<'a> { grid, rows, local_top_y, + last_repeated_header_end_above, in_last_region, y, x, @@ -574,7 +772,7 @@ impl<'a> GridLayouter<'a> { // Reverse with RTL so that later columns start first. let mut dx = Abs::zero(); - for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + for (x, &col) in self.rcols.iter().enumerate() { let mut dy = Abs::zero(); for row in rows { // We want to only draw the fill starting at the parent @@ -643,18 +841,13 @@ impl<'a> GridLayouter<'a> { .sum() }; let width = self.cell_spanned_width(cell, x); - // In the grid, cell colspans expand to the right, - // so we're at the leftmost (lowest 'x') column - // spanned by the cell. However, in RTL, cells - // expand to the left. Therefore, without the - // offset below, cell fills would start at the - // rightmost visual position of a cell and extend - // over to unrelated columns to the right in RTL. - // We avoid this by ensuring the fill starts at the - // very left of the cell, even with colspan > 1. - let offset = - if self.is_rtl { -width + col } else { Abs::zero() }; - let pos = Point::new(dx + offset, dy); + let mut pos = Point::new(dx, dy); + if self.is_rtl { + // In RTL cells expand to the left, thus the + // position must additionally be offset by the + // cell's width. + pos.x = self.width - (dx + width); + } let size = Size::new(width, height); let rect = Geometry::Rect(size).filled(fill); fills.push((pos, FrameItem::Shape(rect, self.span))); @@ -946,15 +1139,9 @@ impl<'a> GridLayouter<'a> { let frame = self.layout_single_row(engine, disambiguator, first, y)?; self.push_row(frame, y, true); - if self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| y < header.end) - { - // Add to header height. - self.header_height += first; + if let Some(row_height) = &mut self.row_state.current_row_height { + // Add to header height, as we are in a header row. + *row_height += first; } return Ok(()); @@ -963,19 +1150,21 @@ impl<'a> GridLayouter<'a> { // Expand all but the last region. // Skip the first region if the space is eaten up by an fr row. let len = resolved.len(); - for ((i, region), target) in self - .regions - .iter() - .enumerate() - .zip(&mut resolved[..len - 1]) - .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) + for ((i, region), target) in + self.regions + .iter() + .enumerate() + .zip(&mut resolved[..len - 1]) + .skip(self.current.lrows.iter().any(|row| matches!(row, Row::Fr(..))) + as usize) { // Subtract header and footer heights from the region height when - // it's not the first. + // it's not the first. Ignore non-repeating headers as they only + // appear on the first region by definition. target.set_max( region.y - if i > 0 { - self.header_height + self.footer_height + self.current.repeating_header_height + self.current.footer_height } else { Abs::zero() }, @@ -1186,25 +1375,19 @@ impl<'a> GridLayouter<'a> { let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); let frame = self.layout_single_row(engine, disambiguator, resolved, y)?; - if self - .grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|header| y < header.end) - { - // Add to header height. - self.header_height += resolved; + if let Some(row_height) = &mut self.row_state.current_row_height { + // Add to header height, as we are in a header row. + *row_height += resolved; } // Skip to fitting region, but only if we aren't part of an unbreakable - // row group. We use 'in_last_with_offset' so our 'in_last' call - // properly considers that a header and a footer would be added on each - // region break. + // row group. We use 'may_progress_with_repeats' to stop trying if we + // would skip to a region with the same height and where the same + // headers would be repeated. let height = frame.height(); while self.unbreakable_rows_left == 0 && !self.regions.size.y.fits(height) - && !in_last_with_offset(self.regions, self.header_height + self.footer_height) + && self.may_progress_with_repeats() { self.finish_region(engine, false)?; @@ -1236,10 +1419,9 @@ impl<'a> GridLayouter<'a> { } let mut output = Frame::soft(Size::new(self.width, height)); - let mut pos = Point::zero(); + let mut offset = Point::zero(); - // Reverse the column order when using RTL. - for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + for (x, &rcol) in self.rcols.iter().enumerate() { if let Some(cell) = self.grid.cell(x, y) { // Rowspans have a separate layout step if cell.rowspan.get() == 1 { @@ -1257,25 +1439,17 @@ impl<'a> GridLayouter<'a> { let frame = layout_cell(cell, engine, disambiguator, self.styles, pod)? .into_frame(); - let mut pos = pos; + let mut pos = offset; if self.is_rtl { - // In the grid, cell colspans expand to the right, - // so we're at the leftmost (lowest 'x') column - // spanned by the cell. However, in RTL, cells - // expand to the left. Therefore, without the - // offset below, the cell's contents would be laid out - // starting at its rightmost visual position and extend - // over to unrelated cells to its right in RTL. - // We avoid this by ensuring the rendered cell starts at - // the very left of the cell, even with colspan > 1. - let offset = -width + rcol; - pos.x += offset; + // In RTL cells expand to the left, thus the position + // must additionally be offset by the cell's width. + pos.x = self.width - (pos.x + width); } output.push_frame(pos, frame); } } - pos.x += rcol; + offset.x += rcol; } Ok(output) @@ -1302,8 +1476,8 @@ impl<'a> GridLayouter<'a> { pod.backlog = &heights[1..]; // Layout the row. - let mut pos = Point::zero(); - for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { + let mut offset = Point::zero(); + for (x, &rcol) in self.rcols.iter().enumerate() { if let Some(cell) = self.grid.cell(x, y) { // Rowspans have a separate layout step if cell.rowspan.get() == 1 { @@ -1314,17 +1488,19 @@ impl<'a> GridLayouter<'a> { let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?; for (output, frame) in outputs.iter_mut().zip(fragment) { - let mut pos = pos; + let mut pos = offset; if self.is_rtl { - let offset = -width + rcol; - pos.x += offset; + // In RTL cells expand to the left, thus the + // position must additionally be offset by the + // cell's width. + pos.x = self.width - (offset.x + width); } output.push_frame(pos, frame); } } } - pos.x += rcol; + offset.x += rcol; } Ok(Fragment::frames(outputs)) @@ -1335,8 +1511,13 @@ impl<'a> GridLayouter<'a> { /// will be pushed for this particular row. It can be `false` for rows /// spanning multiple regions. fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) { + if !self.row_state.in_active_repeatable { + // There is now a row after the rows equipped with orphan + // prevention, so no need to keep moving them anymore. + self.flush_orphans(); + } self.regions.size.y -= frame.height(); - self.lrows.push(Row::Frame(frame, y, is_last)); + self.current.lrows.push(Row::Frame(frame, y, is_last)); } /// Finish rows for one region. @@ -1345,68 +1526,73 @@ impl<'a> GridLayouter<'a> { engine: &mut Engine, last: bool, ) -> SourceResult<()> { + // The latest rows have orphan prevention (headers) and no other rows + // were placed, so remove those rows and try again in a new region, + // unless this is the last region. + if let Some(orphan_snapshot) = self.current.lrows_orphan_snapshot.take() { + if !last { + self.current.lrows.truncate(orphan_snapshot); + self.current.repeated_header_rows = + self.current.repeated_header_rows.min(orphan_snapshot); + + if orphan_snapshot == 0 { + // Removed all repeated headers. + self.current.last_repeated_header_end = 0; + } + } + } + if self + .current .lrows .last() .is_some_and(|row| self.grid.is_gutter_track(row.index())) { // Remove the last row in the region if it is a gutter row. - self.lrows.pop().unwrap(); + self.current.lrows.pop().unwrap(); + self.current.repeated_header_rows = + self.current.repeated_header_rows.min(self.current.lrows.len()); } - // If no rows other than the footer have been laid out so far, and - // there are rows beside the footer, then don't lay it out at all. - // This check doesn't apply, and is thus overridden, when there is a - // header. - let mut footer_would_be_orphan = self.lrows.is_empty() - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) - && self - .grid - .footer - .as_ref() - .and_then(Repeatable::as_repeated) - .is_some_and(|footer| footer.start != 0); - - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - if self.grid.rows.len() > header.end - && self - .grid - .footer - .as_ref() - .and_then(Repeatable::as_repeated) - .is_none_or(|footer| footer.start != header.end) - && self.lrows.last().is_some_and(|row| row.index() < header.end) - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) - { - // Header and footer would be alone in this region, but there are more - // rows beyond the header and the footer. Push an empty region. - self.lrows.clear(); - footer_would_be_orphan = true; - } - } + // If no rows other than the footer have been laid out so far + // (e.g. due to header orphan prevention), and there are rows + // beside the footer, then don't lay it out at all. + // + // It is worth noting that the footer is made non-repeatable at + // the grid resolving stage if it is short-lived, that is, if + // it is at the start of the table (or right after headers at + // the start of the table). + // + // TODO(subfooters): explicitly check for short-lived footers. + // TODO(subfooters): widow prevention for non-repeated footers with a + // similar mechanism / when implementing multiple footers. + let footer_would_be_widow = matches!(&self.grid.footer, Some(footer) if footer.repeated) + && self.current.lrows.is_empty() + && self.current.could_progress_at_top; let mut laid_out_footer_start = None; - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - // Don't layout the footer if it would be alone with the header in - // the page, and don't layout it twice. - if !footer_would_be_orphan - && self.lrows.iter().all(|row| row.index() < footer.start) - { - laid_out_footer_start = Some(footer.start); - self.layout_footer(footer, engine, self.finished.len())?; + if !footer_would_be_widow { + if let Some(footer) = &self.grid.footer { + // Don't layout the footer if it would be alone with the header + // in the page (hence the widow check), and don't layout it + // twice (check below). + // + // TODO(subfooters): this check can be replaced by a vector of + // repeating footers in the future, and/or some "pending + // footers" vector for footers we're about to place. + if footer.repeated + && self.current.lrows.iter().all(|row| row.index() < footer.start) + { + laid_out_footer_start = Some(footer.start); + self.layout_footer(footer, engine, self.finished.len())?; + } } } // Determine the height of existing rows in the region. let mut used = Abs::zero(); let mut fr = Fr::zero(); - for row in &self.lrows { + for row in &self.current.lrows { match row { Row::Frame(frame, _, _) => used += frame.height(), Row::Fr(v, _, _) => fr += *v, @@ -1415,9 +1601,9 @@ impl<'a> GridLayouter<'a> { // Determine the size of the grid in this region, expanding fully if // there are fr rows. - let mut size = Size::new(self.width, used).min(self.initial); - if fr.get() > 0.0 && self.initial.y.is_finite() { - size.y = self.initial.y; + let mut size = Size::new(self.width, used).min(self.current.initial); + if fr.get() > 0.0 && self.current.initial.y.is_finite() { + size.y = self.current.initial.y; } // The frame for the region. @@ -1425,9 +1611,10 @@ impl<'a> GridLayouter<'a> { let mut pos = Point::zero(); let mut rrows = vec![]; let current_region = self.finished.len(); + let mut repeated_header_row_height = Abs::zero(); // Place finished rows and layout fractional rows. - for row in std::mem::take(&mut self.lrows) { + for (i, row) in std::mem::take(&mut self.current.lrows).into_iter().enumerate() { let (frame, y, is_last) = match row { Row::Frame(frame, y, is_last) => (frame, y, is_last), Row::Fr(v, y, disambiguator) => { @@ -1438,6 +1625,9 @@ impl<'a> GridLayouter<'a> { }; let height = frame.height(); + if i < self.current.repeated_header_rows { + repeated_header_row_height += height; + } // Ensure rowspans which span this row will have enough space to // be laid out over it later. @@ -1516,7 +1706,11 @@ impl<'a> GridLayouter<'a> { // we have to check the same index again in the next // iteration. let rowspan = self.rowspans.remove(i); - self.layout_rowspan(rowspan, Some((&mut output, &rrows)), engine)?; + self.layout_rowspan( + rowspan, + Some((&mut output, repeated_header_row_height)), + engine, + )?; } else { i += 1; } @@ -1527,21 +1721,40 @@ impl<'a> GridLayouter<'a> { pos.y += height; } - self.finish_region_internal(output, rrows); + self.finish_region_internal( + output, + rrows, + FinishedHeaderRowInfo { + repeated_amount: self.current.repeated_header_rows, + last_repeated_header_end: self.current.last_repeated_header_end, + repeated_height: repeated_header_row_height, + }, + ); if !last { + self.current.repeated_header_rows = 0; + self.current.last_repeated_header_end = 0; + self.current.repeating_header_height = Abs::zero(); + self.current.repeating_header_heights.clear(); + let disambiguator = self.finished.len(); - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { + if let Some(footer) = + self.grid.footer.as_ref().and_then(Repeatable::as_repeated) + { self.prepare_footer(footer, engine, disambiguator)?; } - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - // Add a header to the new region. - self.layout_header(header, engine, disambiguator)?; - } - // Ensure rows don't try to overrun the footer. - self.regions.size.y -= self.footer_height; + // Note that header layout will only subtract this again if it has + // to skip regions to fit headers, so there is no risk of + // subtracting this twice. + self.regions.size.y -= self.current.footer_height; + self.current.initial_after_repeats = self.regions.size.y; + + if !self.repeating_headers.is_empty() || !self.pending_headers.is_empty() { + // Add headers to the new region. + self.layout_active_headers(engine)?; + } } Ok(()) @@ -1553,11 +1766,26 @@ impl<'a> GridLayouter<'a> { &mut self, output: Frame, resolved_rows: Vec, + header_row_info: FinishedHeaderRowInfo, ) { self.finished.push(output); self.rrows.push(resolved_rows); self.regions.next(); - self.initial = self.regions.size; + self.current.initial = self.regions.size; + + // Repeats haven't been laid out yet, so in the meantime, this will + // represent the initial height after repeats laid out so far, and will + // be gradually updated when preparing footers and repeating headers. + self.current.initial_after_repeats = self.current.initial.y; + + self.current.could_progress_at_top = self.regions.may_progress(); + + if !self.grid.headers.is_empty() { + self.finished_header_rows.push(header_row_info); + } + + // Ensure orphan prevention is handled before resolving rows. + debug_assert!(self.current.lrows_orphan_snapshot.is_none()); } } @@ -1572,13 +1800,3 @@ pub(super) fn points( offset }) } - -/// Checks if the first region of a sequence of regions is the last usable -/// region, assuming that the last region will always be occupied by some -/// specific offset height, even after calling `.next()`, due to some -/// additional logic which adds content automatically on each region turn (in -/// our case, headers). -pub(super) fn in_last_with_offset(regions: Regions<'_>, offset: Abs) -> bool { - regions.backlog.is_empty() - && regions.last.is_none_or(|height| regions.size.y + offset == height) -} diff --git a/crates/typst-layout/src/grid/lines.rs b/crates/typst-layout/src/grid/lines.rs index 7549673f1..d5da7e263 100644 --- a/crates/typst-layout/src/grid/lines.rs +++ b/crates/typst-layout/src/grid/lines.rs @@ -391,10 +391,12 @@ pub fn vline_stroke_at_row( /// /// This function assumes columns are sorted by increasing `x`, and rows are /// sorted by increasing `y`. +#[allow(clippy::too_many_arguments)] pub fn hline_stroke_at_column( grid: &CellGrid, rows: &[RowPiece], local_top_y: Option, + header_end_above: Option, in_last_region: bool, y: usize, x: usize, @@ -499,17 +501,15 @@ pub fn hline_stroke_at_column( // Top border stroke and header stroke are generally prioritized, unless // they don't have explicit hline overrides and one or more user-provided // hlines would appear at the same position, which then are prioritized. - let top_stroke_comes_from_header = grid - .header - .as_ref() - .and_then(Repeatable::as_repeated) - .zip(local_top_y) - .is_some_and(|(header, local_top_y)| { - // Ensure the row above us is a repeated header. - // FIXME: Make this check more robust when headers at arbitrary - // positions are added. - local_top_y < header.end && y > header.end - }); + let top_stroke_comes_from_header = header_end_above.zip(local_top_y).is_some_and( + |(last_repeated_header_end, local_top_y)| { + // Check if the last repeated header row is above this line. + // + // Note that `y == last_repeated_header_end` is impossible for a + // strictly repeated header (not in its original position). + local_top_y < last_repeated_header_end && y > last_repeated_header_end + }, + ); // Prioritize the footer's top stroke as well where applicable. let bottom_stroke_comes_from_footer = grid @@ -637,7 +637,7 @@ mod test { }, vec![], vec![], - None, + vec![], None, entries, ) @@ -1175,7 +1175,7 @@ mod test { }, vec![], vec![], - None, + vec![], None, entries, ) @@ -1268,6 +1268,7 @@ mod test { grid, &rows, y.checked_sub(1), + None, true, y, x, @@ -1461,6 +1462,7 @@ mod test { grid, &rows, y.checked_sub(1), + None, true, y, x, @@ -1506,6 +1508,7 @@ mod test { grid, &rows, if y == 4 { Some(2) } else { y.checked_sub(1) }, + None, true, y, x, diff --git a/crates/typst-layout/src/grid/repeated.rs b/crates/typst-layout/src/grid/repeated.rs index 22d2a09ef..8db33df5e 100644 --- a/crates/typst-layout/src/grid/repeated.rs +++ b/crates/typst-layout/src/grid/repeated.rs @@ -1,57 +1,446 @@ +use std::ops::Deref; + use typst_library::diag::SourceResult; use typst_library::engine::Engine; use typst_library::layout::grid::resolve::{Footer, Header, Repeatable}; use typst_library::layout::{Abs, Axes, Frame, Regions}; -use super::layouter::GridLayouter; +use super::layouter::{GridLayouter, RowState}; use super::rowspans::UnbreakableRowGroup; -impl GridLayouter<'_> { - /// Layouts the header's rows. - /// Skips regions as necessary. - pub fn layout_header( +impl<'a> GridLayouter<'a> { + /// Checks whether a region break could help a situation where we're out of + /// space for the next row. The criteria are: + /// + /// 1. If we could progress at the top of the region, that indicates the + /// region has a backlog, or (if we're at the first region) a region break + /// is at all possible (`regions.last` is `Some()`), so that's sufficient. + /// + /// 2. Otherwise, we may progress if another region break is possible + /// (`regions.last` is still `Some()`) and non-repeating rows have been + /// placed, since that means the space they occupy will be available in the + /// next region. + #[inline] + pub fn may_progress_with_repeats(&self) -> bool { + // TODO(subfooters): check below isn't enough to detect non-repeating + // footers... we can also change 'initial_after_repeats' to stop being + // calculated if there were any non-repeating footers. + self.current.could_progress_at_top + || self.regions.last.is_some() + && self.regions.size.y != self.current.initial_after_repeats + } + + pub fn place_new_headers( + &mut self, + consecutive_header_count: &mut usize, + engine: &mut Engine, + ) -> SourceResult<()> { + *consecutive_header_count += 1; + let (consecutive_headers, new_upcoming_headers) = + self.upcoming_headers.split_at(*consecutive_header_count); + + if new_upcoming_headers.first().is_some_and(|next_header| { + consecutive_headers.last().is_none_or(|latest_header| { + !latest_header.short_lived + && next_header.range.start == latest_header.range.end + }) && !next_header.short_lived + }) { + // More headers coming, so wait until we reach them. + return Ok(()); + } + + self.upcoming_headers = new_upcoming_headers; + *consecutive_header_count = 0; + + let [first_header, ..] = consecutive_headers else { + self.flush_orphans(); + return Ok(()); + }; + + // Assuming non-conflicting headers sorted by increasing y, this must + // be the header with the lowest level (sorted by increasing levels). + let first_level = first_header.level; + + // Stop repeating conflicting headers, even if the new headers are + // short-lived or won't repeat. + // + // If we go to a new region before the new headers fit alongside their + // children (or in general, for short-lived), the old headers should + // not be displayed anymore. + let first_conflicting_pos = + self.repeating_headers.partition_point(|h| h.level < first_level); + self.repeating_headers.truncate(first_conflicting_pos); + + // Ensure upcoming rows won't see that these headers will occupy any + // space in future regions anymore. + for removed_height in + self.current.repeating_header_heights.drain(first_conflicting_pos..) + { + self.current.repeating_header_height -= removed_height; + } + + // Layout short-lived headers immediately. + if consecutive_headers.last().is_some_and(|h| h.short_lived) { + // No chance of orphans as we're immediately placing conflicting + // headers afterwards, which basically are not headers, for all intents + // and purposes. It is therefore guaranteed that all new headers have + // been placed at least once. + self.flush_orphans(); + + // Layout each conflicting header independently, without orphan + // prevention (as they don't go into 'pending_headers'). + // These headers are short-lived as they are immediately followed by a + // header of the same or lower level, such that they never actually get + // to repeat. + self.layout_new_headers(consecutive_headers, true, engine)?; + } else { + // Let's try to place pending headers at least once. + // This might be a waste as we could generate an orphan and thus have + // to try to place old and new headers all over again, but that happens + // for every new region anyway, so it's rather unavoidable. + let snapshot_created = + self.layout_new_headers(consecutive_headers, false, engine)?; + + // Queue the new headers for layout. They will remain in this + // vector due to orphan prevention. + // + // After the first subsequent row is laid out, move to repeating, as + // it's then confirmed the headers won't be moved due to orphan + // prevention anymore. + self.pending_headers = consecutive_headers; + + if !snapshot_created { + // Region probably couldn't progress. + // + // Mark new pending headers as final and ensure there isn't a + // snapshot. + self.flush_orphans(); + } + } + + Ok(()) + } + + /// Lays out rows belonging to a header, returning the calculated header + /// height only for that header. Indicates to the laid out rows that they + /// should inform their laid out heights if appropriate (auto or fixed + /// size rows only). + #[inline] + fn layout_header_rows( &mut self, header: &Header, engine: &mut Engine, disambiguator: usize, - ) -> SourceResult<()> { - let header_rows = - self.simulate_header(header, &self.regions, engine, disambiguator)?; - let mut skipped_region = false; - while self.unbreakable_rows_left == 0 - && !self.regions.size.y.fits(header_rows.height + self.footer_height) - && self.regions.may_progress() - { - // Advance regions without any output until we can place the - // header and the footer. - self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); - skipped_region = true; + as_short_lived: bool, + ) -> SourceResult { + let mut header_height = Abs::zero(); + for y in header.range.clone() { + header_height += self + .layout_row_with_state( + y, + engine, + disambiguator, + RowState { + current_row_height: Some(Abs::zero()), + in_active_repeatable: !as_short_lived, + }, + )? + .current_row_height + .unwrap_or_default(); + } + Ok(header_height) + } + + /// This function should be called each time an additional row has been + /// laid out in a region to indicate that orphan prevention has succeeded. + /// + /// It removes the current orphan snapshot and flushes pending headers, + /// such that a non-repeating header won't try to be laid out again + /// anymore, and a repeating header will begin to be part of + /// `repeating_headers`. + pub fn flush_orphans(&mut self) { + self.current.lrows_orphan_snapshot = None; + self.flush_pending_headers(); + } + + /// Indicates all currently pending headers have been successfully placed + /// once, since another row has been placed after them, so they are + /// certainly not orphans. + pub fn flush_pending_headers(&mut self) { + if self.pending_headers.is_empty() { + return; } - // Reset the header height for this region. - // It will be re-calculated when laying out each header row. - self.header_height = Abs::zero(); - - if let Some(Repeatable::Repeated(footer)) = &self.grid.footer { - if skipped_region { - // Simulate the footer again; the region's 'full' might have - // changed. - self.footer_height = self - .simulate_footer(footer, &self.regions, engine, disambiguator)? - .height; + for header in self.pending_headers { + if header.repeated { + // Vector remains sorted by increasing levels: + // - 'pending_headers' themselves are sorted, since we only + // push non-mutually-conflicting headers at a time. + // - Before pushing new pending headers in + // 'layout_new_pending_headers', we truncate repeating headers + // to remove anything with the same or higher levels as the + // first pending header. + // - Assuming it was sorted before, that truncation only keeps + // elements with a lower level. + // - Therefore, by pushing this header to the end, it will have + // a level larger than all the previous headers, and is thus + // in its 'correct' position. + self.repeating_headers.push(header); } } - // Header is unbreakable. + self.pending_headers = Default::default(); + } + + /// Lays out the rows of repeating and pending headers at the top of the + /// region. + /// + /// Assumes the footer height for the current region has already been + /// calculated. Skips regions as necessary to fit all headers and all + /// footers. + pub fn layout_active_headers(&mut self, engine: &mut Engine) -> SourceResult<()> { + // Generate different locations for content in headers across its + // repetitions by assigning a unique number for each one. + let disambiguator = self.finished.len(); + + let header_height = self.simulate_header_height( + self.repeating_headers + .iter() + .copied() + .chain(self.pending_headers.iter().map(Repeatable::deref)), + &self.regions, + engine, + disambiguator, + )?; + + // We already take the footer into account below. + // While skipping regions, footer height won't be automatically + // re-calculated until the end. + let mut skipped_region = false; + while self.unbreakable_rows_left == 0 + && !self.regions.size.y.fits(header_height) + && self.may_progress_with_repeats() + { + // Advance regions without any output until we can place the + // header and the footer. + self.finish_region_internal( + Frame::soft(Axes::splat(Abs::zero())), + vec![], + Default::default(), + ); + + // TODO(layout model): re-calculate heights of headers and footers + // on each region if 'full' changes? (Assuming height doesn't + // change for now...) + // + // Would remove the footer height update below (move it here). + skipped_region = true; + + self.regions.size.y -= self.current.footer_height; + self.current.initial_after_repeats = self.regions.size.y; + } + + if let Some(footer) = &self.grid.footer { + if footer.repeated && skipped_region { + // Simulate the footer again; the region's 'full' might have + // changed. + self.regions.size.y += self.current.footer_height; + self.current.footer_height = self + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height; + self.regions.size.y -= self.current.footer_height; + } + } + + let repeating_header_rows = + total_header_row_count(self.repeating_headers.iter().copied()); + + let pending_header_rows = + total_header_row_count(self.pending_headers.iter().map(Repeatable::deref)); + + // Group of headers is unbreakable. // Thus, no risk of 'finish_region' being recursively called from // within 'layout_row'. - self.unbreakable_rows_left += header.end; - for y in 0..header.end { - self.layout_row(y, engine, disambiguator)?; + self.unbreakable_rows_left += repeating_header_rows + pending_header_rows; + + self.current.last_repeated_header_end = + self.repeating_headers.last().map(|h| h.range.end).unwrap_or_default(); + + // Reset the header height for this region. + // It will be re-calculated when laying out each header row. + self.current.repeating_header_height = Abs::zero(); + self.current.repeating_header_heights.clear(); + + debug_assert!(self.current.lrows.is_empty()); + debug_assert!(self.current.lrows_orphan_snapshot.is_none()); + let may_progress = self.may_progress_with_repeats(); + + if may_progress { + // Enable orphan prevention for headers at the top of the region. + // Otherwise, we will flush pending headers below, after laying + // them out. + // + // It is very rare for this to make a difference as we're usually + // at the 'last' region after the first skip, at which the snapshot + // is handled by 'layout_new_headers'. Either way, we keep this + // here for correctness. + self.current.lrows_orphan_snapshot = Some(self.current.lrows.len()); } + + // Use indices to avoid double borrow. We don't mutate headers in + // 'layout_row' so this is fine. + let mut i = 0; + while let Some(&header) = self.repeating_headers.get(i) { + let header_height = + self.layout_header_rows(header, engine, disambiguator, false)?; + self.current.repeating_header_height += header_height; + + // We assume that this vector will be sorted according + // to increasing levels like 'repeating_headers' and + // 'pending_headers' - and, in particular, their union, as this + // vector is pushed repeating heights from both. + // + // This is guaranteed by: + // 1. We always push pending headers after repeating headers, + // as we assume they don't conflict because we remove + // conflicting repeating headers when pushing a new pending + // header. + // + // 2. We push in the same order as each. + // + // 3. This vector is also modified when pushing a new pending + // header, where we remove heights for conflicting repeating + // headers which have now stopped repeating. They are always at + // the end and new pending headers respect the existing sort, + // so the vector will remain sorted. + self.current.repeating_header_heights.push(header_height); + + i += 1; + } + + self.current.repeated_header_rows = self.current.lrows.len(); + self.current.initial_after_repeats = self.regions.size.y; + + let mut has_non_repeated_pending_header = false; + for header in self.pending_headers { + if !header.repeated { + self.current.initial_after_repeats = self.regions.size.y; + has_non_repeated_pending_header = true; + } + let header_height = + self.layout_header_rows(header, engine, disambiguator, false)?; + if header.repeated { + self.current.repeating_header_height += header_height; + self.current.repeating_header_heights.push(header_height); + } + } + + if !has_non_repeated_pending_header { + self.current.initial_after_repeats = self.regions.size.y; + } + + if !may_progress { + // Flush pending headers immediately, as placing them again later + // won't help. + self.flush_orphans(); + } + Ok(()) } + /// Lays out headers found for the first time during row layout. + /// + /// If 'short_lived' is true, these headers are immediately followed by + /// a conflicting header, so it is assumed they will not be pushed to + /// pending headers. + /// + /// Returns whether orphan prevention was successfully setup, or couldn't + /// due to short-lived headers or the region couldn't progress. + pub fn layout_new_headers( + &mut self, + headers: &'a [Repeatable
], + short_lived: bool, + engine: &mut Engine, + ) -> SourceResult { + // At first, only consider the height of the given headers. However, + // for upcoming regions, we will have to consider repeating headers as + // well. + let header_height = self.simulate_header_height( + headers.iter().map(Repeatable::deref), + &self.regions, + engine, + 0, + )?; + + while self.unbreakable_rows_left == 0 + && !self.regions.size.y.fits(header_height) + && self.may_progress_with_repeats() + { + // Note that, after the first region skip, the new headers will go + // at the top of the region, but after the repeating headers that + // remained (which will be automatically placed in 'finish_region'). + self.finish_region(engine, false)?; + } + + // Remove new headers at the end of the region if the upcoming row + // doesn't fit. + // TODO(subfooters): what if there is a footer right after it? + let should_snapshot = !short_lived + && self.current.lrows_orphan_snapshot.is_none() + && self.may_progress_with_repeats(); + + if should_snapshot { + // If we don't enter this branch while laying out non-short lived + // headers, that means we will have to immediately flush pending + // headers and mark them as final, since trying to place them in + // the next page won't help get more space. + self.current.lrows_orphan_snapshot = Some(self.current.lrows.len()); + } + + let mut at_top = self.regions.size.y == self.current.initial_after_repeats; + + self.unbreakable_rows_left += + total_header_row_count(headers.iter().map(Repeatable::deref)); + + for header in headers { + let header_height = self.layout_header_rows(header, engine, 0, false)?; + + // Only store this header height if it is actually going to + // become a pending header. Otherwise, pretend it's not a + // header... This is fine for consumers of 'header_height' as + // it is guaranteed this header won't appear in a future + // region, so multi-page rows and cells can effectively ignore + // this header. + if !short_lived && header.repeated { + self.current.repeating_header_height += header_height; + self.current.repeating_header_heights.push(header_height); + if at_top { + self.current.initial_after_repeats = self.regions.size.y; + } + } else { + at_top = false; + } + } + + Ok(should_snapshot) + } + + /// Calculates the total expected height of several headers. + pub fn simulate_header_height<'h: 'a>( + &self, + headers: impl IntoIterator, + regions: &Regions<'_>, + engine: &mut Engine, + disambiguator: usize, + ) -> SourceResult { + let mut height = Abs::zero(); + for header in headers { + height += + self.simulate_header(header, regions, engine, disambiguator)?.height; + } + Ok(height) + } + /// Simulate the header's group of rows. pub fn simulate_header( &self, @@ -66,8 +455,8 @@ impl GridLayouter<'_> { // assume that the amount of unbreakable rows following the first row // in the header will be precisely the rows in the header. self.simulate_unbreakable_row_group( - 0, - Some(header.end), + header.range.start, + Some(header.range.end - header.range.start), regions, engine, disambiguator, @@ -91,11 +480,22 @@ impl GridLayouter<'_> { { // Advance regions without any output until we can place the // footer. - self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); + self.finish_region_internal( + Frame::soft(Axes::splat(Abs::zero())), + vec![], + Default::default(), + ); skipped_region = true; } - self.footer_height = if skipped_region { + // TODO(subfooters): Consider resetting header height etc. if we skip + // region. (Maybe move that step to `finish_region_internal`.) + // + // That is unnecessary at the moment as 'prepare_footers' is only + // called at the start of the region, so header height is always zero + // and no headers were placed so far, but what about when we can have + // footers in the middle of the region? Let's think about this then. + self.current.footer_height = if skipped_region { // Simulate the footer again; the region's 'full' might have // changed. self.simulate_footer(footer, &self.regions, engine, disambiguator)? @@ -118,12 +518,22 @@ impl GridLayouter<'_> { // Ensure footer rows have their own height available. // Won't change much as we're creating an unbreakable row group // anyway, so this is mostly for correctness. - self.regions.size.y += self.footer_height; + self.regions.size.y += self.current.footer_height; + let repeats = self.grid.footer.as_ref().is_some_and(|f| f.repeated); let footer_len = self.grid.rows.len() - footer.start; self.unbreakable_rows_left += footer_len; + for y in footer.start..self.grid.rows.len() { - self.layout_row(y, engine, disambiguator)?; + self.layout_row_with_state( + y, + engine, + disambiguator, + RowState { + in_active_repeatable: repeats, + ..Default::default() + }, + )?; } Ok(()) @@ -144,10 +554,18 @@ impl GridLayouter<'_> { // in the footer will be precisely the rows in the footer. self.simulate_unbreakable_row_group( footer.start, - Some(self.grid.rows.len() - footer.start), + Some(footer.end - footer.start), regions, engine, disambiguator, ) } } + +/// The total amount of rows in the given list of headers. +#[inline] +pub fn total_header_row_count<'h>( + headers: impl IntoIterator, +) -> usize { + headers.into_iter().map(|h| h.range.end - h.range.start).sum() +} diff --git a/crates/typst-layout/src/grid/rowspans.rs b/crates/typst-layout/src/grid/rowspans.rs index 21992ed02..02ea14813 100644 --- a/crates/typst-layout/src/grid/rowspans.rs +++ b/crates/typst-layout/src/grid/rowspans.rs @@ -3,9 +3,8 @@ use typst_library::engine::Engine; use typst_library::foundations::Resolve; use typst_library::layout::grid::resolve::Repeatable; use typst_library::layout::{Abs, Axes, Frame, Point, Region, Regions, Size, Sizing}; -use typst_utils::MaybeReverseIter; -use super::layouter::{in_last_with_offset, points, Row, RowPiece}; +use super::layouter::{points, Row}; use super::{layout_cell, Cell, GridLayouter}; /// All information needed to layout a single rowspan. @@ -23,6 +22,10 @@ pub struct Rowspan { /// specified for the parent cell's `breakable` field. pub is_effectively_unbreakable: bool, /// The horizontal offset of this rowspan in all regions. + /// + /// This is the offset from the text direction start, meaning that, on RTL + /// grids, this is the offset from the right of the grid, whereas, on LTR + /// grids, it is the offset from the left. pub dx: Abs, /// The vertical offset of this rowspan in the first region. pub dy: Abs, @@ -87,10 +90,10 @@ pub struct CellMeasurementData<'layouter> { impl GridLayouter<'_> { /// Layout a rowspan over the already finished regions, plus the current - /// region's frame and resolved rows, if it wasn't finished yet (because - /// we're being called from `finish_region`, but note that this function is - /// also called once after all regions are finished, in which case - /// `current_region_data` is `None`). + /// region's frame and height of resolved header rows, if it wasn't + /// finished yet (because we're being called from `finish_region`, but note + /// that this function is also called once after all regions are finished, + /// in which case `current_region_data` is `None`). /// /// We need to do this only once we already know the heights of all /// spanned rows, which is only possible after laying out the last row @@ -98,7 +101,7 @@ impl GridLayouter<'_> { pub fn layout_rowspan( &mut self, rowspan_data: Rowspan, - current_region_data: Option<(&mut Frame, &[RowPiece])>, + current_region_data: Option<(&mut Frame, Abs)>, engine: &mut Engine, ) -> SourceResult<()> { let Rowspan { @@ -118,10 +121,11 @@ impl GridLayouter<'_> { // Nothing to layout. return Ok(()); }; - let first_column = self.rcols[x]; let cell = self.grid.cell(x, y).unwrap(); let width = self.cell_spanned_width(cell, x); - let dx = if self.is_rtl { dx - width + first_column } else { dx }; + // In RTL cells expand to the left, thus the position + // must additionally be offset by the cell's width. + let dx = if self.is_rtl { self.width - (dx + width) } else { dx }; // Prepare regions. let size = Size::new(width, *first_height); @@ -142,11 +146,31 @@ impl GridLayouter<'_> { // Push the layouted frames directly into the finished frames. let fragment = layout_cell(cell, engine, disambiguator, self.styles, pod)?; - let (current_region, current_rrows) = current_region_data.unzip(); - for ((i, finished), frame) in self + let (current_region, current_header_row_height) = current_region_data.unzip(); + + // Clever trick to process finished header rows: + // - If there are grid headers, the vector will be filled with one + // finished header row height per region, so, chaining with the height + // for the current one, we get the header row height for each region. + // + // - But if there are no grid headers, the vector will be empty, so in + // theory the regions and resolved header row heights wouldn't match. + // But that's fine - 'current_header_row_height' can only be either + // 'Some(zero)' or 'None' in such a case, and for all other rows we + // append infinite zeros. That is, in such a case, the resolved header + // row height is always zero, so that's our fallback. + let finished_header_rows = self + .finished_header_rows + .iter() + .map(|info| info.repeated_height) + .chain(current_header_row_height) + .chain(std::iter::repeat(Abs::zero())); + + for ((i, (finished, header_dy)), frame) in self .finished .iter_mut() .chain(current_region.into_iter()) + .zip(finished_header_rows) .skip(first_region) .enumerate() .zip(fragment) @@ -158,22 +182,9 @@ impl GridLayouter<'_> { } else { // The rowspan continuation starts after the header (thus, // at a position after the sum of the laid out header - // rows). - if let Some(Repeatable::Repeated(header)) = &self.grid.header { - let header_rows = self - .rrows - .get(i) - .map(Vec::as_slice) - .or(current_rrows) - .unwrap_or(&[]) - .iter() - .take_while(|row| row.y < header.end); - - header_rows.map(|row| row.height).sum() - } else { - // Without a header, start at the very top of the region. - Abs::zero() - } + // rows). Without a header, this is zero, so the rowspan can + // start at the very top of the region as usual. + header_dy }; finished.push_frame(Point::new(dx, dy), frame); @@ -185,10 +196,8 @@ impl GridLayouter<'_> { /// Checks if a row contains the beginning of one or more rowspan cells. /// If so, adds them to the rowspans vector. pub fn check_for_rowspans(&mut self, disambiguator: usize, y: usize) { - // We will compute the horizontal offset of each rowspan in advance. - // For that reason, we must reverse the column order when using RTL. - let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl)); - for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) { + let offsets = points(self.rcols.iter().copied()); + for (x, dx) in (0..self.rcols.len()).zip(offsets) { let Some(cell) = self.grid.cell(x, y) else { continue; }; @@ -229,15 +238,13 @@ impl GridLayouter<'_> { // current row is dynamic and depends on the amount of upcoming // unbreakable cells (with or without a rowspan setting). let mut amount_unbreakable_rows = None; - if let Some(Repeatable::NotRepeated(header)) = &self.grid.header { - if current_row < header.end { - // Non-repeated header, so keep it unbreakable. - amount_unbreakable_rows = Some(header.end); - } - } - if let Some(Repeatable::NotRepeated(footer)) = &self.grid.footer { - if current_row >= footer.start { + if let Some(footer) = &self.grid.footer { + if !footer.repeated && current_row >= footer.start { // Non-repeated footer, so keep it unbreakable. + // + // TODO(subfooters): This will become unnecessary + // once non-repeated footers are treated differently and + // have widow prevention. amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start); } } @@ -252,10 +259,7 @@ impl GridLayouter<'_> { // Skip to fitting region. while !self.regions.size.y.fits(row_group.height) - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) + && self.may_progress_with_repeats() { self.finish_region(engine, false)?; } @@ -394,16 +398,29 @@ impl GridLayouter<'_> { // auto rows don't depend on the backlog, as they only span one // region. if breakable - && (matches!(self.grid.header, Some(Repeatable::Repeated(_))) - || matches!(self.grid.footer, Some(Repeatable::Repeated(_)))) + && (!self.repeating_headers.is_empty() + || !self.pending_headers.is_empty() + || matches!(&self.grid.footer, Some(footer) if footer.repeated)) { // Subtract header and footer height from all upcoming regions // when measuring the cell, including the last repeated region. // // This will update the 'custom_backlog' vector with the // updated heights of the upcoming regions. + // + // We predict that header height will only include that of + // repeating headers, as we can assume non-repeating headers in + // the first region have been successfully placed, unless + // something didn't fit on the first region of the auto row, + // but we will only find that out after measurement, and if + // that happens, we discard the measurement and try again. let mapped_regions = self.regions.map(&mut custom_backlog, |size| { - Size::new(size.x, size.y - self.header_height - self.footer_height) + Size::new( + size.x, + size.y + - self.current.repeating_header_height + - self.current.footer_height, + ) }); // Callees must use the custom backlog instead of the current @@ -457,6 +474,7 @@ impl GridLayouter<'_> { // Height of the rowspan covered by spanned rows in the current // region. let laid_out_height: Abs = self + .current .lrows .iter() .filter_map(|row| match row { @@ -504,7 +522,12 @@ impl GridLayouter<'_> { .iter() .copied() .chain(std::iter::once(if breakable { - self.initial.y - self.header_height - self.footer_height + // Here we are calculating the available height for a + // rowspan from the top of the current region, so + // we have to use initial header heights (note that + // header height can change in the middle of the + // region). + self.current.initial_after_repeats } else { // When measuring unbreakable auto rows, infinite // height is available for content to expand. @@ -516,11 +539,13 @@ impl GridLayouter<'_> { // rowspan's already laid out heights with the current // region's height and current backlog to ensure a good // level of accuracy in the measurements. - let backlog = self - .regions - .backlog - .iter() - .map(|&size| size - self.header_height - self.footer_height); + // + // Assume only repeating headers will survive starting at + // the next region. + let backlog = self.regions.backlog.iter().map(|&size| { + size - self.current.repeating_header_height + - self.current.footer_height + }); heights_up_to_current_region.chain(backlog).collect::>() } else { @@ -534,10 +559,10 @@ impl GridLayouter<'_> { height = *rowspan_height; backlog = None; full = rowspan_full; - last = self - .regions - .last - .map(|size| size - self.header_height - self.footer_height); + last = self.regions.last.map(|size| { + size - self.current.repeating_header_height + - self.current.footer_height + }); } else { // The rowspan started in the current region, as its vector // of heights in regions is currently empty. @@ -739,10 +764,11 @@ impl GridLayouter<'_> { simulated_regions.next(); disambiguator += 1; - // Subtract the initial header and footer height, since that's the - // height we used when subtracting from the region backlog's + // Subtract the repeating header and footer height, since that's + // the height we used when subtracting from the region backlog's // heights while measuring cells. - simulated_regions.size.y -= self.header_height + self.footer_height; + simulated_regions.size.y -= + self.current.repeating_header_height + self.current.footer_height; } if let Some(original_last_resolved_size) = last_resolved_size { @@ -874,12 +900,8 @@ impl GridLayouter<'_> { // which, when used and combined with upcoming spanned rows, covers all // of the requested rowspan height, we give up. for _attempt in 0..5 { - let rowspan_simulator = RowspanSimulator::new( - disambiguator, - simulated_regions, - self.header_height, - self.footer_height, - ); + let rowspan_simulator = + RowspanSimulator::new(disambiguator, simulated_regions, &self.current); let total_spanned_height = rowspan_simulator.simulate_rowspan_layout( y, @@ -961,7 +983,8 @@ impl GridLayouter<'_> { { extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero()); simulated_regions.next(); - simulated_regions.size.y -= self.header_height + self.footer_height; + simulated_regions.size.y -= + self.current.repeating_header_height + self.current.footer_height; disambiguator += 1; } simulated_regions.size.y -= extra_amount_to_grow; @@ -978,10 +1001,17 @@ struct RowspanSimulator<'a> { finished: usize, /// The state of regions during the simulation. regions: Regions<'a>, - /// The height of the header in the currently simulated region. + /// The total height of headers in the currently simulated region. header_height: Abs, - /// The height of the footer in the currently simulated region. + /// The total height of footers in the currently simulated region. footer_height: Abs, + /// Whether `self.regions.may_progress()` was `true` at the top of the + /// region, indicating we can progress anywhere in the current region, + /// even right after a repeated header. + could_progress_at_top: bool, + /// Available height after laying out repeated headers at the top of the + /// currently simulated region. + initial_after_repeats: Abs, /// The total spanned height so far in the simulation. total_spanned_height: Abs, /// Height of the latest spanned gutter row in the simulation. @@ -995,14 +1025,19 @@ impl<'a> RowspanSimulator<'a> { fn new( finished: usize, regions: Regions<'a>, - header_height: Abs, - footer_height: Abs, + current: &super::layouter::Current, ) -> Self { Self { finished, regions, - header_height, - footer_height, + // There can be no new headers or footers within a multi-page + // rowspan, since headers and footers are unbreakable, so + // assuming the repeating header height and footer height + // won't change is safe. + header_height: current.repeating_header_height, + footer_height: current.footer_height, + could_progress_at_top: current.could_progress_at_top, + initial_after_repeats: current.initial_after_repeats, total_spanned_height: Abs::zero(), latest_spanned_gutter_height: Abs::zero(), } @@ -1051,10 +1086,7 @@ impl<'a> RowspanSimulator<'a> { 0, )?; while !self.regions.size.y.fits(row_group.height) - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) + && self.may_progress_with_repeats() { self.finish_region(layouter, engine)?; } @@ -1076,10 +1108,7 @@ impl<'a> RowspanSimulator<'a> { let mut skipped_region = false; while unbreakable_rows_left == 0 && !self.regions.size.y.fits(height) - && !in_last_with_offset( - self.regions, - self.header_height + self.footer_height, - ) + && self.may_progress_with_repeats() { self.finish_region(layouter, engine)?; @@ -1125,23 +1154,37 @@ impl<'a> RowspanSimulator<'a> { // our simulation checks what happens AFTER the auto row, so we can // just use the original backlog from `self.regions`. let disambiguator = self.finished; - let header_height = - if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { - layouter - .simulate_header(header, &self.regions, engine, disambiguator)? - .height - } else { - Abs::zero() - }; - let footer_height = - if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer { - layouter - .simulate_footer(footer, &self.regions, engine, disambiguator)? - .height - } else { - Abs::zero() - }; + let (repeating_headers, header_height) = if !layouter.repeating_headers.is_empty() + || !layouter.pending_headers.is_empty() + { + // Only repeating headers have survived after the first region + // break. + let repeating_headers = layouter.repeating_headers.iter().copied().chain( + layouter.pending_headers.iter().filter_map(Repeatable::as_repeated), + ); + + let header_height = layouter.simulate_header_height( + repeating_headers.clone(), + &self.regions, + engine, + disambiguator, + )?; + + (Some(repeating_headers), header_height) + } else { + (None, Abs::zero()) + }; + + let footer_height = if let Some(footer) = + layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated) + { + layouter + .simulate_footer(footer, &self.regions, engine, disambiguator)? + .height + } else { + Abs::zero() + }; let mut skipped_region = false; @@ -1154,19 +1197,24 @@ impl<'a> RowspanSimulator<'a> { skipped_region = true; } - if let Some(Repeatable::Repeated(header)) = &layouter.grid.header { + if let Some(repeating_headers) = repeating_headers { self.header_height = if skipped_region { // Simulate headers again, at the new region, as // the full region height may change. - layouter - .simulate_header(header, &self.regions, engine, disambiguator)? - .height + layouter.simulate_header_height( + repeating_headers, + &self.regions, + engine, + disambiguator, + )? } else { header_height }; } - if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer { + if let Some(footer) = + layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated) + { self.footer_height = if skipped_region { // Simulate footers again, at the new region, as // the full region height may change. @@ -1183,6 +1231,7 @@ impl<'a> RowspanSimulator<'a> { // header or footer (as an invariant, any rowspans spanning any header // or footer rows are fully contained within that header's or footer's rows). self.regions.size.y -= self.header_height + self.footer_height; + self.initial_after_repeats = self.regions.size.y; Ok(()) } @@ -1199,8 +1248,18 @@ impl<'a> RowspanSimulator<'a> { self.regions.next(); self.finished += 1; + self.could_progress_at_top = self.regions.may_progress(); self.simulate_header_footer_layout(layouter, engine) } + + /// Similar to [`GridLayouter::may_progress_with_repeats`] but for rowspan + /// simulation. + #[inline] + fn may_progress_with_repeats(&self) -> bool { + self.could_progress_at_top + || self.regions.last.is_some() + && self.regions.size.y != self.initial_after_repeats + } } /// Subtracts some size from the end of a vector of sizes. diff --git a/crates/typst-layout/src/image.rs b/crates/typst-layout/src/image.rs index 3e5b7d8bd..a8f4a0c81 100644 --- a/crates/typst-layout/src/image.rs +++ b/crates/typst-layout/src/image.rs @@ -1,6 +1,6 @@ use std::ffi::OsStr; -use typst_library::diag::{warning, At, SourceResult, StrResult}; +use typst_library::diag::{warning, At, LoadedWithin, SourceResult, StrResult}; use typst_library::engine::Engine; use typst_library::foundations::{Bytes, Derived, Packed, Smart, StyleChain}; use typst_library::introspection::Locator; @@ -27,17 +27,17 @@ pub fn layout_image( // Take the format that was explicitly defined, or parse the extension, // or try to detect the format. - let Derived { source, derived: data } = &elem.source; + let Derived { source, derived: loaded } = &elem.source; let format = match elem.format(styles) { Smart::Custom(v) => v, - Smart::Auto => determine_format(source, data).at(span)?, + Smart::Auto => determine_format(source, &loaded.data).at(span)?, }; // Warn the user if the image contains a foreign object. Not perfect // because the svg could also be encoded, but that's an edge case. if format == ImageFormat::Vector(VectorFormat::Svg) { let has_foreign_object = - data.as_str().is_ok_and(|s| s.contains(" ImageKind::Raster( RasterImage::new( - data.clone(), + loaded.data.clone(), format, elem.icc(styles).as_ref().map(|icc| icc.derived.clone()), ) @@ -61,11 +61,11 @@ pub fn layout_image( ), ImageFormat::Vector(VectorFormat::Svg) => ImageKind::Svg( SvgImage::with_fonts( - data.clone(), + loaded.data.clone(), engine.world, &families(styles).map(|f| f.as_str()).collect::>(), ) - .at(span)?, + .within(loaded)?, ), }; @@ -147,6 +147,7 @@ fn determine_format(source: &DataSource, data: &Bytes) -> StrResult "jpg" | "jpeg" => return Ok(ExchangeFormat::Jpg.into()), "gif" => return Ok(ExchangeFormat::Gif.into()), "svg" | "svgz" => return Ok(VectorFormat::Svg.into()), + "webp" => return Ok(ExchangeFormat::Webp.into()), _ => {} } } diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index 31512604f..ada048c7d 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -690,13 +690,34 @@ fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) { let breakpoint = if point == text.len() { Breakpoint::Mandatory } else { + const OBJ_REPLACE: char = '\u{FFFC}'; match lb.get(c) { - // Fix for: https://github.com/unicode-org/icu4x/issues/4146 - LineBreak::Glue | LineBreak::WordJoiner | LineBreak::ZWJ => continue, LineBreak::MandatoryBreak | LineBreak::CarriageReturn | LineBreak::LineFeed | LineBreak::NextLine => Breakpoint::Mandatory, + + // https://github.com/typst/typst/issues/5489 + // + // OBJECT-REPLACEMENT-CHARACTERs provide Contingent Break + // opportunities before and after by default. This behaviour + // is however tailorable, see: + // https://www.unicode.org/reports/tr14/#CB + // https://www.unicode.org/reports/tr14/#TailorableBreakingRules + // https://www.unicode.org/reports/tr14/#LB20 + // + // Don't provide a line breaking opportunity between a LTR- + // ISOLATE (or any other Combining Mark) and an OBJECT- + // REPLACEMENT-CHARACTER representing an inline item, if the + // LTR-ISOLATE could end up as the only character on the + // previous line. + LineBreak::CombiningMark + if text[point..].starts_with(OBJ_REPLACE) + && last + c.len_utf8() == point => + { + continue; + } + _ => Breakpoint::Normal, } }; diff --git a/crates/typst-layout/src/inline/mod.rs b/crates/typst-layout/src/inline/mod.rs index 5ef820d07..6cafb9b00 100644 --- a/crates/typst-layout/src/inline/mod.rs +++ b/crates/typst-layout/src/inline/mod.rs @@ -9,6 +9,7 @@ mod prepare; mod shaping; pub use self::box_::layout_box; +pub use self::shaping::create_shape_plan; use comemo::{Track, Tracked, TrackedMut}; use typst_library::diag::SourceResult; diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index 159619eb3..935a86b38 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -1,18 +1,16 @@ use std::borrow::Cow; use std::fmt::{self, Debug, Formatter}; -use std::str::FromStr; use std::sync::Arc; use az::SaturatingAs; -use ecow::EcoString; use rustybuzz::{BufferFlags, ShapePlan, UnicodeBuffer}; use ttf_parser::Tag; use typst_library::engine::Engine; use typst_library::foundations::{Smart, StyleChain}; use typst_library::layout::{Abs, Dir, Em, Frame, FrameItem, Point, Size}; use typst_library::text::{ - families, features, is_default_ignorable, variant, Font, FontFamily, FontVariant, - Glyph, Lang, Region, TextEdgeBounds, TextElem, TextItem, + families, features, is_default_ignorable, language, variant, Font, FontFamily, + FontVariant, Glyph, Lang, Region, TextEdgeBounds, TextElem, TextItem, }; use typst_library::World; use typst_utils::SliceExt; @@ -20,7 +18,7 @@ use unicode_bidi::{BidiInfo, Level as BidiLevel}; use unicode_script::{Script, UnicodeScript}; use super::{decorate, Item, Range, SpanMapper}; -use crate::modifiers::{FrameModifiers, FrameModify}; +use crate::modifiers::FrameModifyText; /// The result of shaping text. /// @@ -295,6 +293,8 @@ impl<'a> ShapedText<'a> { + justification_left + justification_right, x_offset: shaped.x_offset + justification_left, + y_advance: Em::zero(), + y_offset: Em::zero(), range: (shaped.range.start - range.start).saturating_as() ..(shaped.range.end - range.start).saturating_as(), span, @@ -327,7 +327,7 @@ impl<'a> ShapedText<'a> { offset += width; } - frame.modify(&FrameModifiers::get_in(self.styles)); + frame.modify_text(self.styles); frame } @@ -824,12 +824,42 @@ fn shape_segment<'a>( // Add the glyph to the shaped output. if info.glyph_id != 0 && is_covered(cluster) { - // Determine the text range of the glyph. + // Assume we have the following sequence of (glyph_id, cluster): + // [(120, 0), (80, 0), (3, 3), (755, 4), (69, 4), (424, 13), + // (63, 13), (193, 25), (80, 25), (3, 31) + // + // We then want the sequence of (glyph_id, text_range) to look as follows: + // [(120, 0..3), (80, 0..3), (3, 3..4), (755, 4..13), (69, 4..13), + // (424, 13..25), (63, 13..25), (193, 25..31), (80, 25..31), (3, 31..x)] + // + // Each glyph in the same cluster should be assigned the full text + // range. This is necessary because only this way krilla can + // properly assign `ActualText` attributes in complex shaping + // scenarios. + + // The start of the glyph's text range. let start = base + cluster; - let end = base - + if ltr { i.checked_add(1) } else { i.checked_sub(1) } - .and_then(|last| infos.get(last)) - .map_or(text.len(), |info| info.cluster as usize); + + // Determine the end of the glyph's text range. + let mut k = i; + let step: isize = if ltr { 1 } else { -1 }; + let end = loop { + // If we've reached the end of the glyphs, the `end` of the + // range should be the end of the full text. + let Some((next, next_info)) = k + .checked_add_signed(step) + .and_then(|n| infos.get(n).map(|info| (n, info))) + else { + break base + text.len(); + }; + + // If the cluster doesn't match anymore, we've reached the end. + if next_info.cluster != info.cluster { + break base + next_info.cluster as usize; + } + + k = next; + }; let c = text[cluster..].chars().next().unwrap(); let script = c.script(); @@ -904,7 +934,7 @@ fn shape_segment<'a>( /// Create a shape plan. #[comemo::memoize] -fn create_shape_plan( +pub fn create_shape_plan( font: &Font, direction: rustybuzz::Direction, script: rustybuzz::Script, @@ -922,7 +952,7 @@ fn create_shape_plan( /// Shape the text with tofus from the given font. fn shape_tofus(ctx: &mut ShapingContext, base: usize, text: &str, font: Font) { - let x_advance = font.advance(0).unwrap_or_default(); + let x_advance = font.x_advance(0).unwrap_or_default(); let add_glyph = |(cluster, c): (usize, char)| { let start = base + cluster; let end = start + c.len_utf8(); @@ -1014,20 +1044,8 @@ fn calculate_adjustability(ctx: &mut ShapingContext, lang: Lang, region: Option< /// Difference between non-breaking and normal space. fn nbsp_delta(font: &Font) -> Option { - let space = font.ttf().glyph_index(' ')?.0; let nbsp = font.ttf().glyph_index('\u{00A0}')?.0; - Some(font.advance(nbsp)? - font.advance(space)?) -} - -/// Process the language and region of a style chain into a -/// rustybuzz-compatible BCP 47 language. -fn language(styles: StyleChain) -> rustybuzz::Language { - let mut bcp: EcoString = TextElem::lang_in(styles).as_str().into(); - if let Some(region) = TextElem::region_in(styles) { - bcp.push('-'); - bcp.push_str(region.as_str()); - } - rustybuzz::Language::from_str(&bcp).unwrap() + Some(font.x_advance(nbsp)? - font.space_width()?) } /// Returns true if all glyphs in `glyphs` have ranges within the range `range`. diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index f8d910abf..974788a70 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -96,9 +96,13 @@ pub fn layout_enum( let mut cells = vec![]; let mut locator = locator.split(); - let mut number = - elem.start(styles) - .unwrap_or_else(|| if reversed { elem.children.len() } else { 1 }); + let mut number = elem.start(styles).unwrap_or_else(|| { + if reversed { + elem.children.len() as u64 + } else { + 1 + } + }); let mut parents = EnumElem::parents_in(styles); let full = elem.full(styles); diff --git a/crates/typst-layout/src/math/accent.rs b/crates/typst-layout/src/math/accent.rs index f2dfa2c45..159703b8e 100644 --- a/crates/typst-layout/src/math/accent.rs +++ b/crates/typst-layout/src/math/accent.rs @@ -1,9 +1,12 @@ use typst_library::diag::SourceResult; use typst_library::foundations::{Packed, StyleChain}; use typst_library::layout::{Em, Frame, Point, Size}; -use typst_library::math::{Accent, AccentElem}; +use typst_library::math::AccentElem; -use super::{style_cramped, FrameFragment, GlyphFragment, MathContext, MathFragment}; +use super::{ + style_cramped, style_dtls, style_flac, FrameFragment, GlyphFragment, MathContext, + MathFragment, +}; /// How much the accent can be shorter than the base. const ACCENT_SHORT_FALL: Em = Em::new(0.5); @@ -15,53 +18,71 @@ pub fn layout_accent( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let cramped = style_cramped(); - let mut base = ctx.layout_into_fragment(&elem.base, styles.chain(&cramped))?; + let accent = elem.accent; + let top_accent = !accent.is_bottom(); - // Try to replace a glyph with its dotless variant. - if let MathFragment::Glyph(glyph) = &mut base { - glyph.make_dotless_form(ctx); - } + // Try to replace the base glyph with its dotless variant. + let dtls = style_dtls(); + let base_styles = + if top_accent && elem.dotless(styles) { styles.chain(&dtls) } else { styles }; + + let cramped = style_cramped(); + let base = ctx.layout_into_fragment(&elem.base, base_styles.chain(&cramped))?; // Preserve class to preserve automatic spacing. let base_class = base.class(); let base_attach = base.accent_attach(); - let width = elem.size(styles).relative_to(base.width()); - - let Accent(c) = elem.accent; - let mut glyph = GlyphFragment::new(ctx, styles, c, elem.span()); - - // Try to replace accent glyph with flattened variant. + // Try to replace the accent glyph with its flattened variant. let flattened_base_height = scaled!(ctx, styles, flattened_accent_base_height); - if base.ascent() > flattened_base_height { - glyph.make_flattened_accent_form(ctx); - } + let flac = style_flac(); + let accent_styles = if top_accent && base.ascent() > flattened_base_height { + styles.chain(&flac) + } else { + styles + }; - // Forcing the accent to be at least as large as the base makes it too - // wide in many case. - let short_fall = ACCENT_SHORT_FALL.at(glyph.font_size); - let variant = glyph.stretch_horizontal(ctx, width, short_fall); - let accent = variant.frame; - let accent_attach = variant.accent_attach; + let mut glyph = + GlyphFragment::new_char(ctx.font, accent_styles, accent.0, elem.span())?; + + // Forcing the accent to be at least as large as the base makes it too wide + // in many cases. + let width = elem.size(styles).relative_to(base.width()); + let short_fall = ACCENT_SHORT_FALL.at(glyph.item.size); + glyph.stretch_horizontal(ctx, width - short_fall); + let accent_attach = glyph.accent_attach.0; + let accent = glyph.into_frame(); + + let (gap, accent_pos, base_pos) = if top_accent { + // Descent is negative because the accent's ink bottom is above the + // baseline. Therefore, the default gap is the accent's negated descent + // minus the accent base height. Only if the base is very small, we + // need a larger gap so that the accent doesn't move too low. + let accent_base_height = scaled!(ctx, styles, accent_base_height); + let gap = -accent.descent() - base.ascent().min(accent_base_height); + let accent_pos = Point::with_x(base_attach.0 - accent_attach); + let base_pos = Point::with_y(accent.height() + gap); + (gap, accent_pos, base_pos) + } else { + let gap = -accent.ascent(); + let accent_pos = Point::new(base_attach.1 - accent_attach, base.height() + gap); + let base_pos = Point::zero(); + (gap, accent_pos, base_pos) + }; - // Descent is negative because the accent's ink bottom is above the - // baseline. Therefore, the default gap is the accent's negated descent - // minus the accent base height. Only if the base is very small, we need - // a larger gap so that the accent doesn't move too low. - let accent_base_height = scaled!(ctx, styles, accent_base_height); - let gap = -accent.descent() - base.ascent().min(accent_base_height); let size = Size::new(base.width(), accent.height() + gap + base.height()); - let accent_pos = Point::with_x(base_attach - accent_attach); - let base_pos = Point::with_y(accent.height() + gap); let baseline = base_pos.y + base.ascent(); + let base_italics_correction = base.italics_correction(); let base_text_like = base.is_text_like(); - let base_ascent = match &base { MathFragment::Frame(frame) => frame.base_ascent, _ => base.ascent(), }; + let base_descent = match &base { + MathFragment::Frame(frame) => frame.base_descent, + _ => base.descent(), + }; let mut frame = Frame::soft(size); frame.set_baseline(baseline); @@ -71,6 +92,7 @@ pub fn layout_accent( FrameFragment::new(styles, frame) .with_class(base_class) .with_base_ascent(base_ascent) + .with_base_descent(base_descent) .with_italics_correction(base_italics_correction) .with_accent_attach(base_attach) .with_text_like(base_text_like), diff --git a/crates/typst-layout/src/math/attach.rs b/crates/typst-layout/src/math/attach.rs index e1d7d7c9d..a7f3cad5f 100644 --- a/crates/typst-layout/src/math/attach.rs +++ b/crates/typst-layout/src/math/attach.rs @@ -66,7 +66,6 @@ pub fn layout_attach( let relative_to_width = measure!(t, width).max(measure!(b, width)); stretch_fragment( ctx, - styles, &mut base, Some(Axis::X), Some(relative_to_width), @@ -220,7 +219,6 @@ fn layout_attachments( // Calculate the distance each pre-script extends to the left of the base's // width. let (tl_pre_width, bl_pre_width) = compute_pre_script_widths( - ctx, &base, [tl.as_ref(), bl.as_ref()], (tx_shift, bx_shift), @@ -231,7 +229,6 @@ fn layout_attachments( // base's width. Also calculate each post-script's kerning (we need this for // its position later). let ((tr_post_width, tr_kern), (br_post_width, br_kern)) = compute_post_script_widths( - ctx, &base, [tr.as_ref(), br.as_ref()], (tx_shift, bx_shift), @@ -287,14 +284,13 @@ fn layout_attachments( /// post-script's kerning value. The first tuple is for the post-superscript, /// and the second is for the post-subscript. fn compute_post_script_widths( - ctx: &MathContext, base: &MathFragment, [tr, br]: [Option<&MathFragment>; 2], (tr_shift, br_shift): (Abs, Abs), space_after_post_script: Abs, ) -> ((Abs, Abs), (Abs, Abs)) { let tr_values = tr.map_or_default(|tr| { - let kern = math_kern(ctx, base, tr, tr_shift, Corner::TopRight); + let kern = math_kern(base, tr, tr_shift, Corner::TopRight); (space_after_post_script + tr.width() + kern, kern) }); @@ -302,7 +298,7 @@ fn compute_post_script_widths( // need to shift the post-subscript left by the base's italic correction // (see the kerning algorithm as described in the OpenType MATH spec). let br_values = br.map_or_default(|br| { - let kern = math_kern(ctx, base, br, br_shift, Corner::BottomRight) + let kern = math_kern(base, br, br_shift, Corner::BottomRight) - base.italics_correction(); (space_after_post_script + br.width() + kern, kern) }); @@ -317,19 +313,18 @@ fn compute_post_script_widths( /// extends left of the base's width and the second being the distance the /// pre-subscript extends left of the base's width. fn compute_pre_script_widths( - ctx: &MathContext, base: &MathFragment, [tl, bl]: [Option<&MathFragment>; 2], (tl_shift, bl_shift): (Abs, Abs), space_before_pre_script: Abs, ) -> (Abs, Abs) { let tl_pre_width = tl.map_or_default(|tl| { - let kern = math_kern(ctx, base, tl, tl_shift, Corner::TopLeft); + let kern = math_kern(base, tl, tl_shift, Corner::TopLeft); space_before_pre_script + tl.width() + kern }); let bl_pre_width = bl.map_or_default(|bl| { - let kern = math_kern(ctx, base, bl, bl_shift, Corner::BottomLeft); + let kern = math_kern(base, bl, bl_shift, Corner::BottomLeft); space_before_pre_script + bl.width() + kern }); @@ -434,9 +429,13 @@ fn compute_script_shifts( } if bl.is_some() || br.is_some() { + let descent = match &base { + MathFragment::Frame(frame) => frame.base_descent, + _ => base.descent(), + }; shift_down = shift_down .max(sub_shift_down) - .max(if is_text_like { Abs::zero() } else { base.descent() + sub_drop_min }) + .max(if is_text_like { Abs::zero() } else { descent + sub_drop_min }) .max(measure!(bl, ascent) - sub_top_max) .max(measure!(br, ascent) - sub_top_max); } @@ -467,13 +466,7 @@ fn compute_script_shifts( /// a negative value means shifting the script closer to the base. Requires the /// distance from the base's baseline to the script's baseline, as well as the /// script's corner (tl, tr, bl, br). -fn math_kern( - ctx: &MathContext, - base: &MathFragment, - script: &MathFragment, - shift: Abs, - pos: Corner, -) -> Abs { +fn math_kern(base: &MathFragment, script: &MathFragment, shift: Abs, pos: Corner) -> Abs { // This process is described under the MathKernInfo table in the OpenType // MATH spec. @@ -498,8 +491,8 @@ fn math_kern( // Calculate the sum of kerning values for each correction height. let summed_kern = |height| { - let base_kern = base.kern_at_height(ctx, pos, height); - let attach_kern = script.kern_at_height(ctx, pos.inv(), height); + let base_kern = base.kern_at_height(pos, height); + let attach_kern = script.kern_at_height(pos.inv(), height); base_kern + attach_kern }; diff --git a/crates/typst-layout/src/math/frac.rs b/crates/typst-layout/src/math/frac.rs index 6d3caac45..091f328f6 100644 --- a/crates/typst-layout/src/math/frac.rs +++ b/crates/typst-layout/src/math/frac.rs @@ -109,14 +109,14 @@ fn layout_frac_like( frame.push_frame(denom_pos, denom); if binom { - let mut left = GlyphFragment::new(ctx, styles, '(', span) - .stretch_vertical(ctx, height, short_fall); - left.center_on_axis(ctx); + let mut left = GlyphFragment::new_char(ctx.font, styles, '(', span)?; + left.stretch_vertical(ctx, height - short_fall); + left.center_on_axis(); ctx.push(left); ctx.push(FrameFragment::new(styles, frame)); - let mut right = GlyphFragment::new(ctx, styles, ')', span) - .stretch_vertical(ctx, height, short_fall); - right.center_on_axis(ctx); + let mut right = GlyphFragment::new_char(ctx.font, styles, ')', span)?; + right.stretch_vertical(ctx, height - short_fall); + right.center_on_axis(); ctx.push(right); } else { frame.push( diff --git a/crates/typst-layout/src/math/fragment.rs b/crates/typst-layout/src/math/fragment.rs index 1b508a349..eb85eeb5d 100644 --- a/crates/typst-layout/src/math/fragment.rs +++ b/crates/typst-layout/src/math/fragment.rs @@ -1,28 +1,32 @@ use std::fmt::{self, Debug, Formatter}; -use rustybuzz::Feature; -use ttf_parser::gsub::{AlternateSubstitution, SingleSubstitution, SubstitutionSubtable}; -use ttf_parser::opentype_layout::LayoutTable; -use ttf_parser::{GlyphId, Rect}; +use az::SaturatingAs; +use rustybuzz::{BufferFlags, UnicodeBuffer}; +use ttf_parser::math::{GlyphAssembly, GlyphConstruction, GlyphPart}; +use ttf_parser::GlyphId; +use typst_library::diag::{bail, warning, SourceResult}; use typst_library::foundations::StyleChain; use typst_library::introspection::Tag; use typst_library::layout::{ - Abs, Axis, Corner, Em, Frame, FrameItem, Point, Size, VAlignment, + Abs, Axes, Axis, Corner, Em, Frame, FrameItem, Point, Size, VAlignment, }; use typst_library::math::{EquationElem, MathSize}; -use typst_library::text::{Font, Glyph, Lang, Region, TextElem, TextItem}; -use typst_library::visualize::Paint; +use typst_library::text::{features, language, Font, Glyph, TextElem, TextItem}; use typst_syntax::Span; -use typst_utils::default_math_class; +use typst_utils::{default_math_class, Get}; use unicode_math_class::MathClass; -use super::{stretch_glyph, MathContext, Scaled}; +use super::MathContext; +use crate::inline::create_shape_plan; use crate::modifiers::{FrameModifiers, FrameModify}; +/// Maximum number of times extenders can be repeated. +const MAX_REPEATS: usize = 1024; + +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum MathFragment { Glyph(GlyphFragment), - Variant(VariantFragment), Frame(FrameFragment), Spacing(Abs, bool), Space(Abs), @@ -33,13 +37,18 @@ pub enum MathFragment { impl MathFragment { pub fn size(&self) -> Size { - Size::new(self.width(), self.height()) + match self { + Self::Glyph(glyph) => glyph.size, + Self::Frame(fragment) => fragment.frame.size(), + Self::Spacing(amount, _) => Size::with_x(*amount), + Self::Space(amount) => Size::with_x(*amount), + _ => Size::zero(), + } } pub fn width(&self) -> Abs { match self { - Self::Glyph(glyph) => glyph.width, - Self::Variant(variant) => variant.frame.width(), + Self::Glyph(glyph) => glyph.size.x, Self::Frame(fragment) => fragment.frame.width(), Self::Spacing(amount, _) => *amount, Self::Space(amount) => *amount, @@ -49,8 +58,7 @@ impl MathFragment { pub fn height(&self) -> Abs { match self { - Self::Glyph(glyph) => glyph.height(), - Self::Variant(variant) => variant.frame.height(), + Self::Glyph(glyph) => glyph.size.y, Self::Frame(fragment) => fragment.frame.height(), _ => Abs::zero(), } @@ -58,17 +66,15 @@ impl MathFragment { pub fn ascent(&self) -> Abs { match self { - Self::Glyph(glyph) => glyph.ascent, - Self::Variant(variant) => variant.frame.ascent(), - Self::Frame(fragment) => fragment.frame.baseline(), + Self::Glyph(glyph) => glyph.ascent(), + Self::Frame(fragment) => fragment.frame.ascent(), _ => Abs::zero(), } } pub fn descent(&self) -> Abs { match self { - Self::Glyph(glyph) => glyph.descent, - Self::Variant(variant) => variant.frame.descent(), + Self::Glyph(glyph) => glyph.descent(), Self::Frame(fragment) => fragment.frame.descent(), _ => Abs::zero(), } @@ -85,7 +91,6 @@ impl MathFragment { pub fn class(&self) -> MathClass { match self { Self::Glyph(glyph) => glyph.class, - Self::Variant(variant) => variant.class, Self::Frame(fragment) => fragment.class, Self::Spacing(_, _) => MathClass::Space, Self::Space(_) => MathClass::Space, @@ -98,7 +103,6 @@ impl MathFragment { pub fn math_size(&self) -> Option { match self { Self::Glyph(glyph) => Some(glyph.math_size), - Self::Variant(variant) => Some(variant.math_size), Self::Frame(fragment) => Some(fragment.math_size), _ => None, } @@ -106,8 +110,7 @@ impl MathFragment { pub fn font_size(&self) -> Option { match self { - Self::Glyph(glyph) => Some(glyph.font_size), - Self::Variant(variant) => Some(variant.font_size), + Self::Glyph(glyph) => Some(glyph.item.size), Self::Frame(fragment) => Some(fragment.font_size), _ => None, } @@ -116,7 +119,6 @@ impl MathFragment { pub fn set_class(&mut self, class: MathClass) { match self { Self::Glyph(glyph) => glyph.class = class, - Self::Variant(variant) => variant.class = class, Self::Frame(fragment) => fragment.class = class, _ => {} } @@ -125,7 +127,6 @@ impl MathFragment { pub fn set_limits(&mut self, limits: Limits) { match self { Self::Glyph(glyph) => glyph.limits = limits, - Self::Variant(variant) => variant.limits = limits, Self::Frame(fragment) => fragment.limits = limits, _ => {} } @@ -149,7 +150,6 @@ impl MathFragment { pub fn is_text_like(&self) -> bool { match self { Self::Glyph(glyph) => !glyph.extended_shape, - Self::Variant(variant) => !variant.extended_shape, MathFragment::Frame(frame) => frame.text_like, _ => false, } @@ -158,25 +158,22 @@ impl MathFragment { pub fn italics_correction(&self) -> Abs { match self { Self::Glyph(glyph) => glyph.italics_correction, - Self::Variant(variant) => variant.italics_correction, Self::Frame(fragment) => fragment.italics_correction, _ => Abs::zero(), } } - pub fn accent_attach(&self) -> Abs { + pub fn accent_attach(&self) -> (Abs, Abs) { match self { Self::Glyph(glyph) => glyph.accent_attach, - Self::Variant(variant) => variant.accent_attach, Self::Frame(fragment) => fragment.accent_attach, - _ => self.width() / 2.0, + _ => (self.width() / 2.0, self.width() / 2.0), } } pub fn into_frame(self) -> Frame { match self { Self::Glyph(glyph) => glyph.into_frame(), - Self::Variant(variant) => variant.frame, Self::Frame(fragment) => fragment.frame, Self::Tag(tag) => { let mut frame = Frame::soft(Size::zero()); @@ -190,7 +187,6 @@ impl MathFragment { pub fn limits(&self) -> Limits { match self { MathFragment::Glyph(glyph) => glyph.limits, - MathFragment::Variant(variant) => variant.limits, MathFragment::Frame(fragment) => fragment.limits, _ => Limits::Never, } @@ -198,11 +194,31 @@ impl MathFragment { /// If no kern table is provided for a corner, a kerning amount of zero is /// assumed. - pub fn kern_at_height(&self, ctx: &MathContext, corner: Corner, height: Abs) -> Abs { + pub fn kern_at_height(&self, corner: Corner, height: Abs) -> Abs { match self { Self::Glyph(glyph) => { - kern_at_height(ctx, glyph.font_size, glyph.id, corner, height) - .unwrap_or_default() + // For glyph assemblies we pick either the start or end glyph + // depending on the corner. + let is_vertical = + glyph.item.glyphs.iter().all(|glyph| glyph.y_advance != Em::zero()); + let glyph_index = match (is_vertical, corner) { + (true, Corner::TopLeft | Corner::TopRight) => { + glyph.item.glyphs.len() - 1 + } + (false, Corner::TopRight | Corner::BottomRight) => { + glyph.item.glyphs.len() - 1 + } + _ => 0, + }; + + kern_at_height( + &glyph.item.font, + GlyphId(glyph.item.glyphs[glyph_index].id), + corner, + Em::from_length(height, glyph.item.size), + ) + .unwrap_or_default() + .at(glyph.item.size) } _ => Abs::zero(), } @@ -215,12 +231,6 @@ impl From for MathFragment { } } -impl From for MathFragment { - fn from(variant: VariantFragment) -> Self { - Self::Variant(variant) - } -} - impl From for MathFragment { fn from(fragment: FrameFragment) -> Self { Self::Frame(fragment) @@ -229,264 +239,282 @@ impl From for MathFragment { #[derive(Clone)] pub struct GlyphFragment { - pub id: GlyphId, - pub c: char, - pub font: Font, - pub lang: Lang, - pub region: Option, - pub fill: Paint, - pub shift: Abs, - pub width: Abs, - pub ascent: Abs, - pub descent: Abs, + // Text stuff. + pub item: TextItem, + pub base_glyph: Glyph, + // Math stuff. + pub size: Size, + pub baseline: Option, pub italics_correction: Abs, - pub accent_attach: Abs, - pub font_size: Abs, - pub class: MathClass, + pub accent_attach: (Abs, Abs), pub math_size: MathSize, - pub span: Span, - pub modifiers: FrameModifiers, + pub class: MathClass, pub limits: Limits, pub extended_shape: bool, + pub mid_stretched: Option, + // External frame stuff. + pub modifiers: FrameModifiers, + pub shift: Abs, + pub align: Abs, } impl GlyphFragment { - pub fn new(ctx: &MathContext, styles: StyleChain, c: char, span: Span) -> Self { - let id = ctx.ttf.glyph_index(c).unwrap_or_default(); - let id = Self::adjust_glyph_index(ctx, id); - Self::with_id(ctx, styles, c, id, span) - } - - pub fn try_new( - ctx: &MathContext, + /// Calls `new` with the given character. + pub fn new_char( + font: &Font, styles: StyleChain, c: char, span: Span, - ) -> Option { - let id = ctx.ttf.glyph_index(c)?; - let id = Self::adjust_glyph_index(ctx, id); - Some(Self::with_id(ctx, styles, c, id, span)) + ) -> SourceResult { + Self::new(font, styles, c.encode_utf8(&mut [0; 4]), span) } - pub fn with_id( - ctx: &MathContext, + /// Try to create a new glyph out of the given string. Will bail if the + /// result from shaping the string is not a single glyph or is a tofu. + #[comemo::memoize] + pub fn new( + font: &Font, styles: StyleChain, - c: char, - id: GlyphId, + text: &str, span: Span, - ) -> Self { + ) -> SourceResult { + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + buffer.set_language(language(styles)); + // TODO: Use `rustybuzz::script::MATH` once + // https://github.com/harfbuzz/rustybuzz/pull/165 is released. + buffer.set_script( + rustybuzz::Script::from_iso15924_tag(ttf_parser::Tag::from_bytes(b"math")) + .unwrap(), + ); + buffer.set_direction(rustybuzz::Direction::LeftToRight); + buffer.set_flags(BufferFlags::REMOVE_DEFAULT_IGNORABLES); + + let features = features(styles); + let plan = create_shape_plan( + font, + buffer.direction(), + buffer.script(), + buffer.language().as_ref(), + &features, + ); + + let buffer = rustybuzz::shape_with_plan(font.rusty(), &plan, buffer); + if buffer.len() != 1 { + bail!(span, "did not get a single glyph after shaping {}", text); + } + + let info = buffer.glyph_infos()[0]; + let pos = buffer.glyph_positions()[0]; + + // TODO: add support for coverage and fallback, like in normal text shaping. + if info.glyph_id == 0 { + bail!(span, "current font is missing a glyph for {}", text); + } + + let cluster = info.cluster as usize; + let c = text[cluster..].chars().next().unwrap(); + let limits = Limits::for_char(c); let class = EquationElem::class_in(styles) .or_else(|| default_math_class(c)) .unwrap_or(MathClass::Normal); - let mut fragment = Self { - id, - c, - font: ctx.font.clone(), + let glyph = Glyph { + id: info.glyph_id as u16, + x_advance: font.to_em(pos.x_advance), + x_offset: font.to_em(pos.x_offset), + y_advance: font.to_em(pos.y_advance), + y_offset: font.to_em(pos.y_offset), + range: 0..text.len().saturating_as(), + span: (span, 0), + }; + + let item = TextItem { + font: font.clone(), + size: TextElem::size_in(styles), + fill: TextElem::fill_in(styles).as_decoration(), + stroke: TextElem::stroke_in(styles).map(|s| s.unwrap_or_default()), lang: TextElem::lang_in(styles), region: TextElem::region_in(styles), - fill: TextElem::fill_in(styles).as_decoration(), - shift: TextElem::baseline_in(styles), - font_size: TextElem::size_in(styles), - math_size: EquationElem::size_in(styles), - width: Abs::zero(), - ascent: Abs::zero(), - descent: Abs::zero(), - limits: Limits::for_char(c), - italics_correction: Abs::zero(), - accent_attach: Abs::zero(), - class, - span, - modifiers: FrameModifiers::get_in(styles), - extended_shape: false, + text: text.into(), + glyphs: vec![glyph.clone()], }; - fragment.set_id(ctx, id); - fragment - } - /// Apply GSUB substitutions. - fn adjust_glyph_index(ctx: &MathContext, id: GlyphId) -> GlyphId { - if let Some(glyphwise_tables) = &ctx.glyphwise_tables { - glyphwise_tables.iter().fold(id, |id, table| table.apply(id)) - } else { - id - } + let mut fragment = Self { + item, + base_glyph: glyph, + // Math + math_size: EquationElem::size_in(styles), + class, + limits, + mid_stretched: None, + // Math in need of updating. + extended_shape: false, + italics_correction: Abs::zero(), + accent_attach: (Abs::zero(), Abs::zero()), + size: Size::zero(), + baseline: None, + // Misc + align: Abs::zero(), + shift: TextElem::baseline_in(styles), + modifiers: FrameModifiers::get_in(styles), + }; + fragment.update_glyph(); + Ok(fragment) } /// Sets element id and boxes in appropriate way without changing other /// styles. This is used to replace the glyph with a stretch variant. - pub fn set_id(&mut self, ctx: &MathContext, id: GlyphId) { - let advance = ctx.ttf.glyph_hor_advance(id).unwrap_or_default(); - let italics = italics_correction(ctx, id, self.font_size).unwrap_or_default(); - let bbox = ctx.ttf.glyph_bounding_box(id).unwrap_or(Rect { - x_min: 0, - y_min: 0, - x_max: 0, - y_max: 0, - }); + pub fn update_glyph(&mut self) { + let id = GlyphId(self.item.glyphs[0].id); - let mut width = advance.scaled(ctx, self.font_size); - let accent_attach = - accent_attach(ctx, id, self.font_size).unwrap_or((width + italics) / 2.0); - - let extended_shape = is_extended_shape(ctx, id); + let extended_shape = is_extended_shape(&self.item.font, id); + let italics = italics_correction(&self.item.font, id).unwrap_or_default(); + let width = self.item.width(); if !extended_shape { - width += italics; + self.item.glyphs[0].x_advance += italics; } + let italics = italics.at(self.item.size); - self.id = id; - self.width = width; - self.ascent = bbox.y_max.scaled(ctx, self.font_size); - self.descent = -bbox.y_min.scaled(ctx, self.font_size); + let (ascent, descent) = + ascent_descent(&self.item.font, id).unwrap_or((Em::zero(), Em::zero())); + + // The fallback for accents is half the width plus or minus the italics + // correction. This is similar to how top and bottom attachments are + // shifted. For bottom accents we do not use the accent attach of the + // base as it is meant for top acccents. + let top_accent_attach = accent_attach(&self.item.font, id) + .map(|x| x.at(self.item.size)) + .unwrap_or((width + italics) / 2.0); + let bottom_accent_attach = (width - italics) / 2.0; + + self.baseline = Some(ascent.at(self.item.size)); + self.size = Size::new( + self.item.width(), + ascent.at(self.item.size) + descent.at(self.item.size), + ); self.italics_correction = italics; - self.accent_attach = accent_attach; + self.accent_attach = (top_accent_attach, bottom_accent_attach); self.extended_shape = extended_shape; } - pub fn height(&self) -> Abs { - self.ascent + self.descent + // Reset a GlyphFragment's text field and math properties back to its + // base_id's. This is used to return a glyph to its unstretched state. + pub fn reset_glyph(&mut self) { + self.align = Abs::zero(); + self.item.glyphs = vec![self.base_glyph.clone()]; + self.update_glyph(); } - pub fn into_variant(self) -> VariantFragment { - VariantFragment { - c: self.c, - font_size: self.font_size, - italics_correction: self.italics_correction, - accent_attach: self.accent_attach, - class: self.class, - math_size: self.math_size, - span: self.span, - limits: self.limits, - extended_shape: self.extended_shape, - frame: self.into_frame(), - mid_stretched: None, - } + pub fn baseline(&self) -> Abs { + self.ascent() + } + + /// The distance from the baseline to the top of the frame. + pub fn ascent(&self) -> Abs { + self.baseline.unwrap_or(self.size.y) + } + + /// The distance from the baseline to the bottom of the frame. + pub fn descent(&self) -> Abs { + self.size.y - self.ascent() } pub fn into_frame(self) -> Frame { - let item = TextItem { - font: self.font.clone(), - size: self.font_size, - fill: self.fill, - lang: self.lang, - region: self.region, - text: self.c.into(), - stroke: None, - glyphs: vec![Glyph { - id: self.id.0, - x_advance: Em::from_length(self.width, self.font_size), - x_offset: Em::zero(), - range: 0..self.c.len_utf8() as u16, - span: (self.span, 0), - }], - }; - let size = Size::new(self.width, self.ascent + self.descent); - let mut frame = Frame::soft(size); - frame.set_baseline(self.ascent); - frame.push(Point::with_y(self.ascent + self.shift), FrameItem::Text(item)); + let mut frame = Frame::soft(self.size); + frame.set_baseline(self.baseline()); + frame.push( + Point::with_y(self.ascent() + self.shift + self.align), + FrameItem::Text(self.item), + ); frame.modify(&self.modifiers); frame } - pub fn make_script_size(&mut self, ctx: &MathContext) { - let alt_id = - ctx.ssty_table.as_ref().and_then(|ssty| ssty.try_apply(self.id, None)); - if let Some(alt_id) = alt_id { - self.set_id(ctx, alt_id); - } - } - - pub fn make_script_script_size(&mut self, ctx: &MathContext) { - let alt_id = ctx.ssty_table.as_ref().and_then(|ssty| { - // We explicitly request to apply the alternate set with value 1, - // as opposed to the default value in ssty, as the former - // corresponds to second level scripts and the latter corresponds - // to first level scripts. - ssty.try_apply(self.id, Some(1)) - .or_else(|| ssty.try_apply(self.id, None)) - }); - if let Some(alt_id) = alt_id { - self.set_id(ctx, alt_id); - } - } - - pub fn make_dotless_form(&mut self, ctx: &MathContext) { - let alt_id = - ctx.dtls_table.as_ref().and_then(|dtls| dtls.try_apply(self.id, None)); - if let Some(alt_id) = alt_id { - self.set_id(ctx, alt_id); - } - } - - pub fn make_flattened_accent_form(&mut self, ctx: &MathContext) { - let alt_id = - ctx.flac_table.as_ref().and_then(|flac| flac.try_apply(self.id, None)); - if let Some(alt_id) = alt_id { - self.set_id(ctx, alt_id); - } - } - /// Try to stretch a glyph to a desired height. - pub fn stretch_vertical( - self, - ctx: &mut MathContext, - height: Abs, - short_fall: Abs, - ) -> VariantFragment { - stretch_glyph(ctx, self, height, short_fall, Axis::Y) + pub fn stretch_vertical(&mut self, ctx: &mut MathContext, height: Abs) { + self.stretch(ctx, height, Axis::Y) } /// Try to stretch a glyph to a desired width. - pub fn stretch_horizontal( - self, - ctx: &mut MathContext, - width: Abs, - short_fall: Abs, - ) -> VariantFragment { - stretch_glyph(ctx, self, width, short_fall, Axis::X) + pub fn stretch_horizontal(&mut self, ctx: &mut MathContext, width: Abs) { + self.stretch(ctx, width, Axis::X) + } + + /// Try to stretch a glyph to a desired width or height. + /// + /// The resulting frame may not have the exact desired width or height. + pub fn stretch(&mut self, ctx: &mut MathContext, target: Abs, axis: Axis) { + self.reset_glyph(); + + // If the base glyph is good enough, use it. + let mut advance = self.size.get(axis); + if axis == Axis::X && !self.extended_shape { + // For consistency, we subtract the italics correction from the + // glyph's width if it was added in `update_glyph`. + advance -= self.italics_correction; + } + if target <= advance { + return; + } + + let id = GlyphId(self.item.glyphs[0].id); + let font = self.item.font.clone(); + let Some(construction) = glyph_construction(&font, id, axis) else { return }; + + // Search for a pre-made variant with a good advance. + let mut best_id = id; + let mut best_advance = advance; + for variant in construction.variants { + best_id = variant.variant_glyph; + best_advance = + self.item.font.to_em(variant.advance_measurement).at(self.item.size); + if target <= best_advance { + break; + } + } + + // This is either good or the best we've got. + if target <= best_advance || construction.assembly.is_none() { + self.item.glyphs[0].id = best_id.0; + self.item.glyphs[0].x_advance = + self.item.font.x_advance(best_id.0).unwrap_or_default(); + self.item.glyphs[0].x_offset = Em::zero(); + self.item.glyphs[0].y_advance = + self.item.font.y_advance(best_id.0).unwrap_or_default(); + self.item.glyphs[0].y_offset = Em::zero(); + self.update_glyph(); + return; + } + + // Assemble from parts. + let assembly = construction.assembly.unwrap(); + let min_overlap = min_connector_overlap(&self.item.font) + .unwrap_or_default() + .at(self.item.size); + assemble(ctx, self, assembly, min_overlap, target, axis); + } + + /// Vertically adjust the fragment's frame so that it is centered + /// on the axis. + pub fn center_on_axis(&mut self) { + self.align_on_axis(VAlignment::Horizon); + } + + /// Vertically adjust the fragment's frame so that it is aligned + /// to the given alignment on the axis. + pub fn align_on_axis(&mut self, align: VAlignment) { + let h = self.size.y; + let axis = axis_height(&self.item.font).unwrap().at(self.item.size); + self.align += self.baseline(); + self.baseline = Some(align.inv().position(h + axis * 2.0)); + self.align -= self.baseline(); } } impl Debug for GlyphFragment { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "GlyphFragment({:?})", self.c) - } -} - -#[derive(Clone)] -pub struct VariantFragment { - pub c: char, - pub italics_correction: Abs, - pub accent_attach: Abs, - pub frame: Frame, - pub font_size: Abs, - pub class: MathClass, - pub math_size: MathSize, - pub span: Span, - pub limits: Limits, - pub mid_stretched: Option, - pub extended_shape: bool, -} - -impl VariantFragment { - /// Vertically adjust the fragment's frame so that it is centered - /// on the axis. - pub fn center_on_axis(&mut self, ctx: &MathContext) { - self.align_on_axis(ctx, VAlignment::Horizon) - } - - /// Vertically adjust the fragment's frame so that it is aligned - /// to the given alignment on the axis. - pub fn align_on_axis(&mut self, ctx: &MathContext, align: VAlignment) { - let h = self.frame.height(); - let axis = ctx.constants.axis_height().scaled(ctx, self.font_size); - self.frame.set_baseline(align.inv().position(h + axis * 2.0)); - } -} - -impl Debug for VariantFragment { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "VariantFragment({:?})", self.c) + write!(f, "GlyphFragment({:?})", self.item.text) } } @@ -499,8 +527,9 @@ pub struct FrameFragment { pub limits: Limits, pub spaced: bool, pub base_ascent: Abs, + pub base_descent: Abs, pub italics_correction: Abs, - pub accent_attach: Abs, + pub accent_attach: (Abs, Abs), pub text_like: bool, pub ignorant: bool, } @@ -508,6 +537,7 @@ pub struct FrameFragment { impl FrameFragment { pub fn new(styles: StyleChain, frame: Frame) -> Self { let base_ascent = frame.ascent(); + let base_descent = frame.descent(); let accent_attach = frame.width() / 2.0; Self { frame: frame.modified(&FrameModifiers::get_in(styles)), @@ -517,8 +547,9 @@ impl FrameFragment { limits: Limits::Never, spaced: false, base_ascent, + base_descent, italics_correction: Abs::zero(), - accent_attach, + accent_attach: (accent_attach, accent_attach), text_like: false, ignorant: false, } @@ -540,11 +571,15 @@ impl FrameFragment { Self { base_ascent, ..self } } + pub fn with_base_descent(self, base_descent: Abs) -> Self { + Self { base_descent, ..self } + } + pub fn with_italics_correction(self, italics_correction: Abs) -> Self { Self { italics_correction, ..self } } - pub fn with_accent_attach(self, accent_attach: Abs) -> Self { + pub fn with_accent_attach(self, accent_attach: (Abs, Abs)) -> Self { Self { accent_attach, ..self } } @@ -557,46 +592,47 @@ impl FrameFragment { } } +fn ascent_descent(font: &Font, id: GlyphId) -> Option<(Em, Em)> { + let bbox = font.ttf().glyph_bounding_box(id)?; + Some((font.to_em(bbox.y_max), -font.to_em(bbox.y_min))) +} + /// Look up the italics correction for a glyph. -fn italics_correction(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option { - Some( - ctx.table - .glyph_info? - .italic_corrections? - .get(id)? - .scaled(ctx, font_size), - ) +fn italics_correction(font: &Font, id: GlyphId) -> Option { + font.ttf() + .tables() + .math? + .glyph_info? + .italic_corrections? + .get(id) + .map(|value| font.to_em(value.value)) } /// Loop up the top accent attachment position for a glyph. -fn accent_attach(ctx: &MathContext, id: GlyphId, font_size: Abs) -> Option { - Some( - ctx.table - .glyph_info? - .top_accent_attachments? - .get(id)? - .scaled(ctx, font_size), - ) +fn accent_attach(font: &Font, id: GlyphId) -> Option { + font.ttf() + .tables() + .math? + .glyph_info? + .top_accent_attachments? + .get(id) + .map(|value| font.to_em(value.value)) } /// Look up whether a glyph is an extended shape. -fn is_extended_shape(ctx: &MathContext, id: GlyphId) -> bool { - ctx.table - .glyph_info - .and_then(|info| info.extended_shapes) - .and_then(|info| info.get(id)) +fn is_extended_shape(font: &Font, id: GlyphId) -> bool { + font.ttf() + .tables() + .math + .and_then(|math| math.glyph_info) + .and_then(|glyph_info| glyph_info.extended_shapes) + .and_then(|coverage| coverage.get(id)) .is_some() } /// Look up a kerning value at a specific corner and height. -fn kern_at_height( - ctx: &MathContext, - font_size: Abs, - id: GlyphId, - corner: Corner, - height: Abs, -) -> Option { - let kerns = ctx.table.glyph_info?.kern_infos?.get(id)?; +fn kern_at_height(font: &Font, id: GlyphId, corner: Corner, height: Em) -> Option { + let kerns = font.ttf().tables().math?.glyph_info?.kern_infos?.get(id)?; let kern = match corner { Corner::TopLeft => kerns.top_left, Corner::TopRight => kerns.top_right, @@ -605,11 +641,187 @@ fn kern_at_height( }?; let mut i = 0; - while i < kern.count() && height > kern.height(i)?.scaled(ctx, font_size) { + while i < kern.count() && height > font.to_em(kern.height(i)?.value) { i += 1; } - Some(kern.kern(i)?.scaled(ctx, font_size)) + Some(font.to_em(kern.kern(i)?.value)) +} + +fn axis_height(font: &Font) -> Option { + Some(font.to_em(font.ttf().tables().math?.constants?.axis_height().value)) +} + +pub fn stretch_axes(font: &Font, id: u16) -> Axes { + let id = GlyphId(id); + let horizontal = font + .ttf() + .tables() + .math + .and_then(|math| math.variants) + .and_then(|variants| variants.horizontal_constructions.get(id)) + .is_some(); + let vertical = font + .ttf() + .tables() + .math + .and_then(|math| math.variants) + .and_then(|variants| variants.vertical_constructions.get(id)) + .is_some(); + + Axes::new(horizontal, vertical) +} + +fn min_connector_overlap(font: &Font) -> Option { + font.ttf() + .tables() + .math? + .variants + .map(|variants| font.to_em(variants.min_connector_overlap)) +} + +fn glyph_construction(font: &Font, id: GlyphId, axis: Axis) -> Option { + font.ttf() + .tables() + .math? + .variants + .map(|variants| match axis { + Axis::X => variants.horizontal_constructions, + Axis::Y => variants.vertical_constructions, + })? + .get(id) +} + +/// Assemble a glyph from parts. +fn assemble( + ctx: &mut MathContext, + base: &mut GlyphFragment, + assembly: GlyphAssembly, + min_overlap: Abs, + target: Abs, + axis: Axis, +) { + // Determine the number of times the extenders need to be repeated as well + // as a ratio specifying how much to spread the parts apart + // (0 = maximal overlap, 1 = minimal overlap). + let mut full; + let mut ratio; + let mut repeat = 0; + loop { + full = Abs::zero(); + ratio = 0.0; + + let mut parts = parts(assembly, repeat).peekable(); + let mut growable = Abs::zero(); + + while let Some(part) = parts.next() { + let mut advance = base.item.font.to_em(part.full_advance).at(base.item.size); + if let Some(next) = parts.peek() { + let max_overlap = base + .item + .font + .to_em(part.end_connector_length.min(next.start_connector_length)) + .at(base.item.size); + if max_overlap < min_overlap { + // This condition happening is indicative of a bug in the + // font. + ctx.engine.sink.warn(warning!( + base.item.glyphs[0].span.0, + "glyph has assembly parts with overlap less than minConnectorOverlap"; + hint: "its rendering may appear broken - this is probably a font bug"; + hint: "please file an issue at https://github.com/typst/typst/issues" + )); + } + + advance -= max_overlap; + growable += max_overlap - min_overlap; + } + + full += advance; + } + + if full < target { + let delta = target - full; + ratio = (delta / growable).min(1.0); + full += ratio * growable; + } + + if target <= full || repeat >= MAX_REPEATS { + break; + } + + repeat += 1; + } + + let mut glyphs = vec![]; + let mut parts = parts(assembly, repeat).peekable(); + while let Some(part) = parts.next() { + let mut advance = base.item.font.to_em(part.full_advance).at(base.item.size); + if let Some(next) = parts.peek() { + let max_overlap = base + .item + .font + .to_em(part.end_connector_length.min(next.start_connector_length)) + .at(base.item.size); + advance -= max_overlap; + advance += ratio * (max_overlap - min_overlap); + } + let (x, y) = match axis { + Axis::X => (Em::from_length(advance, base.item.size), Em::zero()), + Axis::Y => (Em::zero(), Em::from_length(advance, base.item.size)), + }; + glyphs.push(Glyph { + id: part.glyph_id.0, + x_advance: x, + x_offset: Em::zero(), + y_advance: y, + y_offset: Em::zero(), + ..base.item.glyphs[0].clone() + }); + } + + match axis { + Axis::X => base.size.x = full, + Axis::Y => { + base.baseline = None; + base.size.y = full; + base.size.x = glyphs + .iter() + .map(|glyph| base.item.font.x_advance(glyph.id).unwrap_or_default()) + .max() + .unwrap_or_default() + .at(base.item.size); + } + } + + base.item.glyphs = glyphs; + base.italics_correction = base + .item + .font + .to_em(assembly.italics_correction.value) + .at(base.item.size); + if axis == Axis::X { + base.accent_attach = (full / 2.0, full / 2.0); + } + base.mid_stretched = None; + base.extended_shape = true; +} + +/// Return an iterator over the assembly's parts with extenders repeated the +/// specified number of times. +fn parts(assembly: GlyphAssembly, repeat: usize) -> impl Iterator + '_ { + assembly.parts.into_iter().flat_map(move |part| { + let count = if part.part_flags.extender() { repeat } else { 1 }; + std::iter::repeat_n(part, count) + }) +} + +pub fn has_dtls_feat(font: &Font) -> bool { + font.ttf() + .tables() + .gsub + .and_then(|gsub| gsub.features.index(ttf_parser::Tag::from_bytes(b"dtls"))) + .is_some() } /// Describes in which situation a frame should use limits for attachments. @@ -662,56 +874,3 @@ impl Limits { fn is_integral_char(c: char) -> bool { ('∫'..='∳').contains(&c) || ('⨋'..='⨜').contains(&c) } - -/// An OpenType substitution table that is applicable to glyph-wise substitutions. -pub enum GlyphwiseSubsts<'a> { - Single(SingleSubstitution<'a>), - Alternate(AlternateSubstitution<'a>, u32), -} - -impl<'a> GlyphwiseSubsts<'a> { - pub fn new(gsub: Option>, feature: Feature) -> Option { - let gsub = gsub?; - let table = gsub - .features - .find(feature.tag) - .and_then(|feature| feature.lookup_indices.get(0)) - .and_then(|index| gsub.lookups.get(index))?; - let table = table.subtables.get::(0)?; - match table { - SubstitutionSubtable::Single(single_glyphs) => { - Some(Self::Single(single_glyphs)) - } - SubstitutionSubtable::Alternate(alt_glyphs) => { - Some(Self::Alternate(alt_glyphs, feature.value)) - } - _ => None, - } - } - - pub fn try_apply( - &self, - glyph_id: GlyphId, - alt_value: Option, - ) -> Option { - match self { - Self::Single(single) => match single { - SingleSubstitution::Format1 { coverage, delta } => coverage - .get(glyph_id) - .map(|_| GlyphId(glyph_id.0.wrapping_add(*delta as u16))), - SingleSubstitution::Format2 { coverage, substitutes } => { - coverage.get(glyph_id).and_then(|idx| substitutes.get(idx)) - } - }, - Self::Alternate(alternate, value) => alternate - .coverage - .get(glyph_id) - .and_then(|idx| alternate.alternate_sets.get(idx)) - .and_then(|set| set.alternates.get(alt_value.unwrap_or(*value) as u16)), - } - } - - pub fn apply(&self, glyph_id: GlyphId) -> GlyphId { - self.try_apply(glyph_id, None).unwrap_or(glyph_id) - } -} diff --git a/crates/typst-layout/src/math/lr.rs b/crates/typst-layout/src/math/lr.rs index bf8235411..a3b5cb05c 100644 --- a/crates/typst-layout/src/math/lr.rs +++ b/crates/typst-layout/src/math/lr.rs @@ -45,20 +45,20 @@ pub fn layout_lr( // Scale up fragments at both ends. match inner_fragments { - [one] => scale(ctx, styles, one, relative_to, height, None), + [one] => scale_if_delimiter(ctx, one, relative_to, height, None), [first, .., last] => { - scale(ctx, styles, first, relative_to, height, Some(MathClass::Opening)); - scale(ctx, styles, last, relative_to, height, Some(MathClass::Closing)); + scale_if_delimiter(ctx, first, relative_to, height, Some(MathClass::Opening)); + scale_if_delimiter(ctx, last, relative_to, height, Some(MathClass::Closing)); } - _ => {} + [] => {} } - // Handle MathFragment::Variant fragments that should be scaled up. + // Handle MathFragment::Glyph fragments that should be scaled up. for fragment in inner_fragments.iter_mut() { - if let MathFragment::Variant(ref mut variant) = fragment { - if variant.mid_stretched == Some(false) { - variant.mid_stretched = Some(true); - scale(ctx, styles, fragment, relative_to, height, Some(MathClass::Large)); + if let MathFragment::Glyph(ref mut glyph) = fragment { + if glyph.mid_stretched == Some(false) { + glyph.mid_stretched = Some(true); + scale(ctx, fragment, relative_to, height); } } } @@ -95,18 +95,9 @@ pub fn layout_mid( let mut fragments = ctx.layout_into_fragments(&elem.body, styles)?; for fragment in &mut fragments { - match fragment { - MathFragment::Glyph(glyph) => { - let mut new = glyph.clone().into_variant(); - new.mid_stretched = Some(false); - new.class = MathClass::Fence; - *fragment = MathFragment::Variant(new); - } - MathFragment::Variant(variant) => { - variant.mid_stretched = Some(false); - variant.class = MathClass::Fence; - } - _ => {} + if let MathFragment::Glyph(ref mut glyph) = fragment { + glyph.mid_stretched = Some(false); + glyph.class = MathClass::Relation; } } @@ -114,10 +105,13 @@ pub fn layout_mid( Ok(()) } -/// Scale a math fragment to a height. -fn scale( +/// Scales a math fragment to a height if it has the class Opening, Closing, or +/// Fence. +/// +/// In case `apply` is `Some(class)`, `class` will be applied to the fragment if +/// it is a delimiter, in a way that cannot be overridden by the user. +fn scale_if_delimiter( ctx: &mut MathContext, - styles: StyleChain, fragment: &mut MathFragment, relative_to: Abs, height: Rel, @@ -127,21 +121,23 @@ fn scale( fragment.class(), MathClass::Opening | MathClass::Closing | MathClass::Fence ) { - // This unwrap doesn't really matter. If it is None, then the fragment - // won't be stretchable anyways. - let short_fall = DELIM_SHORT_FALL.at(fragment.font_size().unwrap_or_default()); - stretch_fragment( - ctx, - styles, - fragment, - Some(Axis::Y), - Some(relative_to), - height, - short_fall, - ); + scale(ctx, fragment, relative_to, height); if let Some(class) = apply { fragment.set_class(class); } } } + +/// Scales a math fragment to a height. +fn scale( + ctx: &mut MathContext, + fragment: &mut MathFragment, + relative_to: Abs, + height: Rel, +) { + // This unwrap doesn't really matter. If it is None, then the fragment + // won't be stretchable anyways. + let short_fall = DELIM_SHORT_FALL.at(fragment.font_size().unwrap_or_default()); + stretch_fragment(ctx, fragment, Some(Axis::Y), Some(relative_to), height, short_fall); +} diff --git a/crates/typst-layout/src/math/mat.rs b/crates/typst-layout/src/math/mat.rs index bf4929026..278b1343e 100644 --- a/crates/typst-layout/src/math/mat.rs +++ b/crates/typst-layout/src/math/mat.rs @@ -1,4 +1,4 @@ -use typst_library::diag::{bail, SourceResult}; +use typst_library::diag::{bail, warning, SourceResult}; use typst_library::foundations::{Content, Packed, Resolve, StyleChain}; use typst_library::layout::{ Abs, Axes, Em, FixedAlignment, Frame, FrameItem, Point, Ratio, Rel, Size, @@ -9,8 +9,8 @@ use typst_library::visualize::{FillRule, FixedStroke, Geometry, LineCap, Shape}; use typst_syntax::Span; use super::{ - alignments, delimiter_alignment, stack, style_for_denominator, AlignmentResult, - FrameFragment, GlyphFragment, LeftRightAlternator, MathContext, DELIM_SHORT_FALL, + alignments, style_for_denominator, AlignmentResult, FrameFragment, GlyphFragment, + LeftRightAlternator, MathContext, DELIM_SHORT_FALL, }; const VERTICAL_PADDING: Ratio = Ratio::new(0.1); @@ -23,67 +23,23 @@ pub fn layout_vec( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let delim = elem.delim(styles); - let frame = layout_vec_body( + let span = elem.span(); + + let column: Vec<&Content> = elem.children.iter().collect(); + let frame = layout_body( ctx, styles, - &elem.children, + &[column], elem.align(styles), - elem.gap(styles), LeftRightAlternator::Right, + None, + Axes::with_y(elem.gap(styles)), + span, + "elements", )?; - layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) -} - -/// Lays out a [`MatElem`]. -#[typst_macros::time(name = "math.mat", span = elem.span())] -pub fn layout_mat( - elem: &Packed, - ctx: &mut MathContext, - styles: StyleChain, -) -> SourceResult<()> { - let augment = elem.augment(styles); - let rows = &elem.rows; - - if let Some(aug) = &augment { - for &offset in &aug.hline.0 { - if offset == 0 || offset.unsigned_abs() >= rows.len() { - bail!( - elem.span(), - "cannot draw a horizontal line after row {} of a matrix with {} rows", - if offset < 0 { rows.len() as isize + offset } else { offset }, - rows.len() - ); - } - } - - let ncols = rows.first().map_or(0, |row| row.len()); - - for &offset in &aug.vline.0 { - if offset == 0 || offset.unsigned_abs() >= ncols { - bail!( - elem.span(), - "cannot draw a vertical line after column {} of a matrix with {} columns", - if offset < 0 { ncols as isize + offset } else { offset }, - ncols - ); - } - } - } - let delim = elem.delim(styles); - let frame = layout_mat_body( - ctx, - styles, - rows, - elem.align(styles), - augment, - Axes::new(elem.column_gap(styles), elem.row_gap(styles)), - elem.span(), - )?; - - layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), elem.span()) + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), span) } /// Lays out a [`CasesElem`]. @@ -93,60 +49,100 @@ pub fn layout_cases( ctx: &mut MathContext, styles: StyleChain, ) -> SourceResult<()> { - let delim = elem.delim(styles); - let frame = layout_vec_body( + let span = elem.span(); + + let column: Vec<&Content> = elem.children.iter().collect(); + let frame = layout_body( ctx, styles, - &elem.children, + &[column], FixedAlignment::Start, - elem.gap(styles), LeftRightAlternator::None, + None, + Axes::with_y(elem.gap(styles)), + span, + "branches", )?; + let delim = elem.delim(styles); let (open, close) = if elem.reverse(styles) { (None, delim.close()) } else { (delim.open(), None) }; - - layout_delimiters(ctx, styles, frame, open, close, elem.span()) + layout_delimiters(ctx, styles, frame, open, close, span) } -/// Layout the inner contents of a vector. -fn layout_vec_body( +/// Lays out a [`MatElem`]. +#[typst_macros::time(name = "math.mat", span = elem.span())] +pub fn layout_mat( + elem: &Packed, ctx: &mut MathContext, styles: StyleChain, - column: &[Content], - align: FixedAlignment, - row_gap: Rel, - alternator: LeftRightAlternator, -) -> SourceResult { - let gap = row_gap.relative_to(ctx.region.size.y); +) -> SourceResult<()> { + let span = elem.span(); + let rows = &elem.rows; + let ncols = rows.first().map_or(0, |row| row.len()); - let denom_style = style_for_denominator(styles); - let mut flat = vec![]; - for child in column { - // We allow linebreaks in cases and vectors, which are functionally - // identical to commas. - flat.extend(ctx.layout_into_run(child, styles.chain(&denom_style))?.rows()); + let augment = elem.augment(styles); + if let Some(aug) = &augment { + for &offset in &aug.hline.0 { + if offset == 0 || offset.unsigned_abs() >= rows.len() { + bail!( + span, + "cannot draw a horizontal line after row {} of a matrix with {} rows", + if offset < 0 { rows.len() as isize + offset } else { offset }, + rows.len() + ); + } + } + + for &offset in &aug.vline.0 { + if offset == 0 || offset.unsigned_abs() >= ncols { + bail!( + span, + "cannot draw a vertical line after column {} of a matrix with {} columns", + if offset < 0 { ncols as isize + offset } else { offset }, + ncols + ); + } + } } - // We pad ascent and descent with the ascent and descent of the paren - // to ensure that normal vectors are aligned with others unless they are - // way too big. - let paren = - GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); - Ok(stack(flat, align, gap, 0, alternator, Some((paren.ascent, paren.descent)))) + + // Transpose rows of the matrix into columns. + let mut row_iters: Vec<_> = rows.iter().map(|i| i.iter()).collect(); + let columns: Vec> = (0..ncols) + .map(|_| row_iters.iter_mut().map(|i| i.next().unwrap()).collect()) + .collect(); + + let frame = layout_body( + ctx, + styles, + &columns, + elem.align(styles), + LeftRightAlternator::Right, + augment, + Axes::new(elem.column_gap(styles), elem.row_gap(styles)), + span, + "cells", + )?; + + let delim = elem.delim(styles); + layout_delimiters(ctx, styles, frame, delim.open(), delim.close(), span) } -/// Layout the inner contents of a matrix. -fn layout_mat_body( +/// Layout the inner contents of a matrix, vector, or cases. +#[allow(clippy::too_many_arguments)] +fn layout_body( ctx: &mut MathContext, styles: StyleChain, - rows: &[Vec], + columns: &[Vec<&Content>], align: FixedAlignment, + alternator: LeftRightAlternator, augment: Option>, gap: Axes>, span: Span, + children: &str, ) -> SourceResult { - let ncols = rows.first().map_or(0, |row| row.len()); - let nrows = rows.len(); + let nrows = columns.first().map_or(0, |col| col.len()); + let ncols = columns.len(); if ncols == 0 || nrows == 0 { return Ok(Frame::soft(Size::zero())); } @@ -178,29 +174,40 @@ fn layout_mat_body( // Before the full matrix body can be laid out, the // individual cells must first be independently laid out // so we can ensure alignment across rows and columns. + let mut cols = vec![vec![]; ncols]; // This variable stores the maximum ascent and descent for each row. let mut heights = vec![(Abs::zero(), Abs::zero()); nrows]; - // We want to transpose our data layout to columns - // before final layout. For efficiency, the columns - // variable is set up here and newly generated - // individual cells are then added to it. - let mut cols = vec![vec![]; ncols]; - let denom_style = style_for_denominator(styles); // We pad ascent and descent with the ascent and descent of the paren // to ensure that normal matrices are aligned with others unless they are // way too big. - let paren = - GlyphFragment::new(ctx, styles.chain(&denom_style), '(', Span::detached()); + let paren = GlyphFragment::new_char( + ctx.font, + styles.chain(&denom_style), + '(', + Span::detached(), + )?; - for (row, (ascent, descent)) in rows.iter().zip(&mut heights) { - for (cell, col) in row.iter().zip(&mut cols) { + for (column, col) in columns.iter().zip(&mut cols) { + for (cell, (ascent, descent)) in column.iter().zip(&mut heights) { + let cell_span = cell.span(); let cell = ctx.layout_into_run(cell, styles.chain(&denom_style))?; - ascent.set_max(cell.ascent().max(paren.ascent)); - descent.set_max(cell.descent().max(paren.descent)); + // We ignore linebreaks in the cells as we can't differentiate + // alignment points for the whole body from ones for a specific + // cell, and multiline cells don't quite make sense at the moment. + if cell.is_multiline() { + ctx.engine.sink.warn(warning!( + cell_span, + "linebreaks are ignored in {}", children; + hint: "use commas instead to separate each line" + )); + } + + ascent.set_max(cell.ascent().max(paren.ascent())); + descent.set_max(cell.descent().max(paren.descent())); col.push(cell); } @@ -222,7 +229,7 @@ fn layout_mat_body( let mut y = Abs::zero(); for (cell, &(ascent, descent)) in col.into_iter().zip(&heights) { - let cell = cell.into_line_frame(&points, LeftRightAlternator::Right); + let cell = cell.into_line_frame(&points, alternator); let pos = Point::new( if points.is_empty() { x + align.position(rcol - cell.width()) @@ -309,19 +316,19 @@ fn layout_delimiters( let target = height + VERTICAL_PADDING.of(height); frame.set_baseline(height / 2.0 + axis); - if let Some(left) = left { - let mut left = GlyphFragment::new(ctx, styles, left, span) - .stretch_vertical(ctx, target, short_fall); - left.align_on_axis(ctx, delimiter_alignment(left.c)); + if let Some(left_c) = left { + let mut left = GlyphFragment::new_char(ctx.font, styles, left_c, span)?; + left.stretch_vertical(ctx, target - short_fall); + left.center_on_axis(); ctx.push(left); } ctx.push(FrameFragment::new(styles, frame)); - if let Some(right) = right { - let mut right = GlyphFragment::new(ctx, styles, right, span) - .stretch_vertical(ctx, target, short_fall); - right.align_on_axis(ctx, delimiter_alignment(right.c)); + if let Some(right_c) = right { + let mut right = GlyphFragment::new_char(ctx.font, styles, right_c, span)?; + right.stretch_vertical(ctx, target - short_fall); + right.center_on_axis(); ctx.push(right); } diff --git a/crates/typst-layout/src/math/mod.rs b/crates/typst-layout/src/math/mod.rs index 708a4443d..5fd22e578 100644 --- a/crates/typst-layout/src/math/mod.rs +++ b/crates/typst-layout/src/math/mod.rs @@ -13,8 +13,6 @@ mod stretch; mod text; mod underover; -use rustybuzz::Feature; -use ttf_parser::Tag; use typst_library::diag::{bail, SourceResult}; use typst_library::engine::Engine; use typst_library::foundations::{ @@ -30,7 +28,7 @@ use typst_library::math::*; use typst_library::model::ParElem; use typst_library::routines::{Arenas, RealizationKind}; use typst_library::text::{ - families, features, variant, Font, LinebreakElem, SpaceElem, TextEdgeBounds, TextElem, + families, variant, Font, LinebreakElem, SpaceElem, TextEdgeBounds, TextElem, }; use typst_library::World; use typst_syntax::Span; @@ -38,11 +36,11 @@ use typst_utils::Numeric; use unicode_math_class::MathClass; use self::fragment::{ - FrameFragment, GlyphFragment, GlyphwiseSubsts, Limits, MathFragment, VariantFragment, + has_dtls_feat, stretch_axes, FrameFragment, GlyphFragment, Limits, MathFragment, }; use self::run::{LeftRightAlternator, MathRun, MathRunFrameBuilder}; use self::shared::*; -use self::stretch::{stretch_fragment, stretch_glyph}; +use self::stretch::stretch_fragment; /// Layout an inline equation (in a paragraph). #[typst_macros::time(span = elem.span())] @@ -58,7 +56,7 @@ pub fn layout_equation_inline( let font = find_math_font(engine, styles, elem.span())?; let mut locator = locator.split(); - let mut ctx = MathContext::new(engine, &mut locator, styles, region, &font); + let mut ctx = MathContext::new(engine, &mut locator, region, &font); let scale_style = style_for_script_scale(&ctx); let styles = styles.chain(&scale_style); @@ -113,7 +111,7 @@ pub fn layout_equation_block( let font = find_math_font(engine, styles, span)?; let mut locator = locator.split(); - let mut ctx = MathContext::new(engine, &mut locator, styles, regions.base(), &font); + let mut ctx = MathContext::new(engine, &mut locator, regions.base(), &font); let scale_style = style_for_script_scale(&ctx); let styles = styles.chain(&scale_style); @@ -374,14 +372,7 @@ struct MathContext<'a, 'v, 'e> { region: Region, // Font-related. font: &'a Font, - ttf: &'a ttf_parser::Face<'a>, - table: ttf_parser::math::Table<'a>, constants: ttf_parser::math::Constants<'a>, - dtls_table: Option>, - flac_table: Option>, - ssty_table: Option>, - glyphwise_tables: Option>>, - space_width: Em, // Mutable. fragments: Vec, } @@ -391,46 +382,20 @@ impl<'a, 'v, 'e> MathContext<'a, 'v, 'e> { fn new( engine: &'v mut Engine<'e>, locator: &'v mut SplitLocator<'a>, - styles: StyleChain<'a>, base: Size, font: &'a Font, ) -> Self { - let math_table = font.ttf().tables().math.unwrap(); - let gsub_table = font.ttf().tables().gsub; - let constants = math_table.constants.unwrap(); - - let feat = |tag: &[u8; 4]| { - GlyphwiseSubsts::new(gsub_table, Feature::new(Tag::from_bytes(tag), 0, ..)) - }; - - let features = features(styles); - let glyphwise_tables = Some( - features - .into_iter() - .filter_map(|feature| GlyphwiseSubsts::new(gsub_table, feature)) - .collect(), - ); - - let ttf = font.ttf(); - let space_width = ttf - .glyph_index(' ') - .and_then(|id| ttf.glyph_hor_advance(id)) - .map(|advance| font.to_em(advance)) - .unwrap_or(THICK); + // These unwraps are safe as the font given is one returned by the + // find_math_font function, which only returns fonts that have a math + // constants table. + let constants = font.ttf().tables().math.unwrap().constants.unwrap(); Self { engine, locator, region: Region::new(base, Axes::splat(false)), font, - ttf, - table: math_table, constants, - dtls_table: feat(b"dtls"), - flac_table: feat(b"flac"), - ssty_table: feat(b"ssty"), - glyphwise_tables, - space_width, fragments: vec![], } } @@ -529,7 +494,8 @@ fn layout_realized( if let Some(elem) = elem.to_packed::() { ctx.push(MathFragment::Tag(elem.tag.clone())); } else if elem.is::() { - ctx.push(MathFragment::Space(ctx.space_width.resolve(styles))); + let space_width = ctx.font.space_width().unwrap_or(THICK); + ctx.push(MathFragment::Space(space_width.resolve(styles))); } else if elem.is::() { ctx.push(MathFragment::Linebreak); } else if let Some(elem) = elem.to_packed::() { diff --git a/crates/typst-layout/src/math/root.rs b/crates/typst-layout/src/math/root.rs index c7f41488e..91b9b16af 100644 --- a/crates/typst-layout/src/math/root.rs +++ b/crates/typst-layout/src/math/root.rs @@ -49,9 +49,9 @@ pub fn layout_root( // Layout root symbol. let target = radicand.height() + thickness + gap; - let sqrt = GlyphFragment::new(ctx, styles, '√', span) - .stretch_vertical(ctx, target, Abs::zero()) - .frame; + let mut sqrt = GlyphFragment::new_char(ctx.font, styles, '√', span)?; + sqrt.stretch_vertical(ctx, target); + let sqrt = sqrt.into_frame(); // Layout the index. let sscript = EquationElem::set_size(MathSize::ScriptScript).wrap(); diff --git a/crates/typst-layout/src/math/run.rs b/crates/typst-layout/src/math/run.rs index ae64368d6..4ec76c253 100644 --- a/crates/typst-layout/src/math/run.rs +++ b/crates/typst-layout/src/math/run.rs @@ -278,6 +278,9 @@ impl MathRun { frame } + /// Convert this run of math fragments into a vector of inline items for + /// paragraph layout. Creates multiple fragments when relation or binary + /// operators are present to allow for line-breaking opportunities later. pub fn into_par_items(self) -> Vec { let mut items = vec![]; @@ -295,21 +298,24 @@ impl MathRun { let mut space_is_visible = false; - let is_relation = |f: &MathFragment| matches!(f.class(), MathClass::Relation); let is_space = |f: &MathFragment| { matches!(f, MathFragment::Space(_) | MathFragment::Spacing(_, _)) }; + let is_line_break_opportunity = |class, next_fragment| match class { + // Don't split when two relations are in a row or when preceding a + // closing parenthesis. + MathClass::Binary => next_fragment != Some(MathClass::Closing), + MathClass::Relation => { + !matches!(next_fragment, Some(MathClass::Relation | MathClass::Closing)) + } + _ => false, + }; let mut iter = self.0.into_iter().peekable(); while let Some(fragment) = iter.next() { - if space_is_visible { - match fragment { - MathFragment::Space(width) | MathFragment::Spacing(width, _) => { - items.push(InlineItem::Space(width, true)); - continue; - } - _ => {} - } + if space_is_visible && is_space(&fragment) { + items.push(InlineItem::Space(fragment.width(), true)); + continue; } let class = fragment.class(); @@ -323,10 +329,9 @@ impl MathRun { frame.push_frame(pos, fragment.into_frame()); empty = false; - if class == MathClass::Binary - || (class == MathClass::Relation - && !iter.peek().map(is_relation).unwrap_or_default()) - { + // Split our current frame when we encounter a binary operator or + // relation so that there is a line-breaking opportunity. + if is_line_break_opportunity(class, iter.peek().map(|f| f.class())) { let mut frame_prev = std::mem::replace(&mut frame, Frame::soft(Size::zero())); diff --git a/crates/typst-layout/src/math/shared.rs b/crates/typst-layout/src/math/shared.rs index 5aebdacac..1f88d2dd7 100644 --- a/crates/typst-layout/src/math/shared.rs +++ b/crates/typst-layout/src/math/shared.rs @@ -1,7 +1,9 @@ use ttf_parser::math::MathValue; +use ttf_parser::Tag; use typst_library::foundations::{Style, StyleChain}; -use typst_library::layout::{Abs, Em, FixedAlignment, Frame, Point, Size, VAlignment}; +use typst_library::layout::{Abs, Em, FixedAlignment, Frame, Point, Size}; use typst_library::math::{EquationElem, MathSize}; +use typst_library::text::{FontFeatures, TextElem}; use typst_utils::LazyHash; use super::{LeftRightAlternator, MathContext, MathFragment, MathRun}; @@ -59,6 +61,16 @@ pub fn style_cramped() -> LazyHash