diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd12d26fc6a5..c17a67caec5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: sudo dpkg --add-architecture i386 sudo apt update sudo apt install -y gcc-multilib g++-multilib libssl-dev:i386 pkg-config:i386 - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0 with: targets: ${{ matrix.bits == 32 && 'i686-unknown-linux-gnu' || '' }} - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 @@ -92,12 +92,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0 with: components: clippy, rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - run: cargo clippy --workspace --all-targets --all-features - - run: cargo clippy --workspace --all-targets --no-default-features + # These crates have cargo features, so we check them separately, or else + # feature unification may mask errors. + - run: cargo clippy --package typst-cli --no-default-features + - run: cargo clippy --package typst-kit --no-default-features + - run: cargo clippy --package typst-timing --no-default-features - run: cargo fmt --check --all - run: cargo doc --workspace --no-deps --document-private-items - run: git diff --exit-code @@ -107,7 +111,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - run: cargo docit compile --format website --deny-warnings - run: cargo docit compile --format pdf --deny-warnings @@ -117,16 +121,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: dtolnay/rust-toolchain@b5826f8eb73f40ac7c19ef631dce17a9680f8129 # 1.92.0 + - uses: dtolnay/rust-toolchain@87eb139fed4b08a67bd1fa429a21d1f5d523e03e # 1.92.0 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - - run: cargo check --workspace + - run: cargo check --workspace --all-targets --all-features fuzz: name: Check fuzzers runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: nightly-2025-10-28 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 @@ -138,7 +142,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: components: miri toolchain: nightly-2025-10-28 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd3dc3e298d3..26e6568884e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install Rust - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0 with: target: ${{ matrix.target }} @@ -99,10 +99,10 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install Rust - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0 - name: Compile documentation - run: cargo docit compile --format pdf + run: cargo docit compile --format pdf --release - name: Rename documentation file run: mv docs/dist/docs.pdf typst-documentation.pdf diff --git a/Cargo.lock b/Cargo.lock index 55c5613dcd4d..ef4142b5525e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -49,15 +49,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -247,7 +247,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -355,9 +355,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.28" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -365,9 +365,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.27" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -378,30 +378,40 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.44" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375f9d8255adeeedd51053574fd8d4ba875ea5fa558e86617b07f09f1680c8b6" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", ] +[[package]] +name = "clap_complete_nushell" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb66bc82eb9c92b1727310ae2c5868df22ae7cf46185bc5c544a4fa71955e49" +dependencies = [ + "clap", + "clap_complete", +] + [[package]] name = "clap_derive" -version = "4.5.28" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clap_mangen" @@ -435,10 +445,10 @@ dependencies = [ [[package]] name = "codex" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0732ab1a27b4ea05e6f9f60a5122c9924dd5123defde0d8e907f58cf643d40e6" +source = "git+https://github.com/typst/codex?rev=2e43ab9#2e43ab90079cf9f9fa670d421c8c38233143b376" dependencies = [ "chinese-number", + "icu_properties", ] [[package]] @@ -468,7 +478,7 @@ dependencies = [ "nom", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -504,7 +514,7 @@ checksum = "a3c400139ba1389ef9e20ad2d87cda68b437a66483aa0da616bdf2cea7413853" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -571,9 +581,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -629,12 +639,9 @@ checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "digest" @@ -675,7 +682,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -710,7 +717,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -837,6 +844,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -967,9 +983,8 @@ checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" [[package]] name = "glifo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae9d48c6d81526ad2f9d5d6e5fddf5f6949ffbc46fcac0db0d061a6e65097019" +version = "0.1.1" +source = "git+https://github.com/linebender/vello?rev=8442ef4#8442ef44019568a05aeb6a04451e5cf503a6c406" dependencies = [ "bytemuck", "foldhash", @@ -977,7 +992,7 @@ dependencies = [ "log", "peniko", "png", - "skrifa", + "skrifa 0.42.1", "smallvec", "vello_common", ] @@ -1001,6 +1016,18 @@ dependencies = [ "crunchy", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "read-fonts 0.41.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1041,29 +1068,27 @@ dependencies = [ [[package]] name = "hayro" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +version = "0.7.0" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "bytemuck", "hayro-interpret", "image", "kurbo", "pic-scale", + "rustc-hash", "vello_cpu", ] [[package]] name = "hayro-ccitt" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "hayro-postscript", ] @@ -1071,8 +1096,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "bitflags 2.11.1", "hayro-cmap", @@ -1082,7 +1106,7 @@ dependencies = [ "phf 0.13.1", "rustc-hash", "siphasher", - "skrifa", + "skrifa 0.42.1", "smallvec", "yoke", ] @@ -1090,8 +1114,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -1099,9 +1122,8 @@ dependencies = [ [[package]] name = "hayro-jpeg2000" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +version = "0.4.0" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "fearless_simd", ] @@ -1109,19 +1131,18 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" [[package]] name = "hayro-svg" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7434c89e920d84e077214a29e69b462a6518754610f732f1124af3948410cb8c" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "base64", "hayro-interpret", "image", "kurbo", + "rustc-hash", "siphasher", "xmlwriter", ] @@ -1129,8 +1150,7 @@ dependencies = [ [[package]] name = "hayro-syntax" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "flate2", "hayro-ccitt", @@ -1145,12 +1165,12 @@ dependencies = [ [[package]] name = "hayro-write" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "flate2", "hayro-syntax", "pdf-writer", + "rustc-hash", ] [[package]] @@ -1173,9 +1193,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hypher" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b24ad5637230df201ab1034d593f1d09bf7f2a9274f2e8897638078579f4265" +checksum = "45f2b4f6b5da34d400d4c112eadcc14512cc5d04307a8eee182586d9bebd7ce6" [[package]] name = "iana-time-zone" @@ -1202,14 +1222,14 @@ dependencies = [ [[package]] name = "icu_collator" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b521b92a2666061ddda902769d8a4cf730b5c9529a845cc1b69770b12a6c9a71" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" dependencies = [ "icu_collator_data", "icu_collections", - "icu_locale", "icu_locale_core", + "icu_locale_fallback", "icu_normalizer", "icu_properties", "icu_provider", @@ -1221,15 +1241,15 @@ dependencies = [ [[package]] name = "icu_collator_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038ed8e5817f2059c2f3efb0945ba78d060d3d25e8f1a1bea5139f821a21a2f0" +checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1242,24 +1262,24 @@ dependencies = [ [[package]] name = "icu_locale" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +checksum = "250ec6d4ac288584d1d0ba29dbfad7737635b7b0e822441f4026a523e3fdb147" dependencies = [ "icu_collections", "icu_locale_core", "icu_locale_data", + "icu_locale_fallback", "icu_provider", - "potential_utf", "tinystr", "zerovec", ] [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1271,15 +1291,35 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.2.0" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1294,16 +1334,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1315,15 +1356,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -1353,26 +1394,27 @@ dependencies = [ [[package]] name = "icu_segmenter" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" dependencies = [ "core_maths", "icu_collections", - "icu_locale", + "icu_locale_fallback", "icu_provider", "icu_segmenter_data", "potential_utf", "serde", + "smallvec", "utf8_iter", "zerovec", ] [[package]] name = "icu_segmenter_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" [[package]] name = "idna" @@ -1550,15 +1592,16 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +source = "git+https://github.com/LaurenzV/krilla?rev=7772dbe#7772dbee5ba576b7b7afe2c59cbe4c2fefe7ce9c" dependencies = [ "base64", "bumpalo", "comemo", + "crc32fast", "flate2", "float-cmp", "gif", + "harfrust", "hayro-write", "image-webp", "imagesize", @@ -1567,9 +1610,8 @@ dependencies = [ "png", "rayon", "rustc-hash", - "rustybuzz", "siphasher", - "skrifa", + "skrifa 0.44.0", "smallvec", "subsetter", "tiny-skia-path", @@ -1581,14 +1623,13 @@ dependencies = [ [[package]] name = "krilla-svg" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1237d7c37b16ca9fbc2e72dde13a10d321f9a44aceee35c062bc0a43bfc7ce16" +source = "git+https://github.com/LaurenzV/krilla?rev=7772dbe#7772dbee5ba576b7b7afe2c59cbe4c2fefe7ce9c" dependencies = [ "flate2", "fontdb", "krilla", "resvg", - "skrifa", + "skrifa 0.44.0", "smallvec", "tiny-skia", "usvg", @@ -1683,6 +1724,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "lipsum" version = "0.9.1" @@ -1737,9 +1784,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.5" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -1852,9 +1899,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1876,9 +1923,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" @@ -1913,7 +1960,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -1988,7 +2035,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2117,7 +2164,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2254,9 +2301,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2287,9 +2334,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -2373,7 +2420,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", - "font-types", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", ] [[package]] @@ -2503,7 +2561,20 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] @@ -2639,7 +2710,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2757,7 +2828,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.39.2", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", ] [[package]] @@ -2783,9 +2864,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "stable_deref_trait" @@ -2839,7 +2920,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2850,7 +2931,7 @@ checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" dependencies = [ "kurbo", "rustc-hash", - "skrifa", + "skrifa 0.42.1", "write-fonts", ] @@ -2875,6 +2956,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -2883,7 +2975,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2934,7 +3026,7 @@ dependencies = [ "fastrand", "getrandom 0.3.1", "once_cell", - "rustix", + "rustix 0.38.44", "windows-sys 0.59.0", ] @@ -2949,17 +3041,17 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.1" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", + "rustix 1.0.8", "windows-sys 0.59.0", ] [[package]] name = "test-wrapper" -version = "0.15.0" +version = "0.15.1" [[package]] name = "thin-vec" @@ -2984,35 +3076,34 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] name = "time" -version = "0.3.37" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.19" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3058,9 +3149,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -3156,18 +3247,22 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typst" -version = "0.15.0" +version = "0.15.1" dependencies = [ "arrayvec", "comemo", "ecow", "rustc-hash", + "typst-bundle", "typst-eval", "typst-html", "typst-layout", "typst-library", "typst-macros", + "typst-pdf", "typst-realize", + "typst-render", + "typst-svg", "typst-syntax", "typst-timing", "typst-utils", @@ -3175,15 +3270,15 @@ dependencies = [ [[package]] name = "typst-assets" -version = "0.15.0" -source = "git+https://github.com/typst/typst-assets?rev=74217e4#74217e4a4c731ceacff20208a4148ea52e7bab77" +version = "0.15.1" +source = "git+https://github.com/typst/typst-assets?rev=94dcb99#94dcb990edb36311ef85162baa26e4a8205e0bf0" dependencies = [ "bitflags 2.11.1", ] [[package]] name = "typst-bundle" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "ecow", @@ -3205,11 +3300,12 @@ dependencies = [ [[package]] name = "typst-cli" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "clap", "clap_complete", + "clap_complete_nushell", "clap_mangen", "codespan-reporting", "color-print", @@ -3268,6 +3364,7 @@ dependencies = [ "typst-layout", "typst-library", "typst-macros", + "typst-pdf", "typst-syntax", "typst-timing", "typst-utils", @@ -3276,12 +3373,12 @@ dependencies = [ [[package]] name = "typst-dev-assets" -version = "0.15.0" -source = "git+https://github.com/typst/typst-dev-assets?rev=a2e29ed#a2e29ed9393121735ce56d9d06deeab701cf35e0" +version = "0.15.1" +source = "git+https://github.com/typst/typst-dev-assets?rev=e26d7ca#e26d7ca70f956ef3e8b329d3d766168ad0b006a5" [[package]] name = "typst-docs" -version = "0.15.0" +version = "0.15.1" dependencies = [ "az", "base64", @@ -3290,13 +3387,14 @@ dependencies = [ "ecow", "either", "heck", + "icu_collator", "open", "proc-macro2", "rayon", "rustc-hash", "serde", "serde_json", - "syn", + "syn 2.0.98", "typst", "typst-assets", "typst-bundle", @@ -3319,7 +3417,7 @@ dependencies = [ [[package]] name = "typst-eval" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "ecow", @@ -3337,12 +3435,13 @@ dependencies = [ [[package]] name = "typst-fuzz" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "libfuzzer-sys", "typst", "typst-assets", + "typst-bundle", "typst-html", "typst-layout", "typst-pdf", @@ -3353,7 +3452,7 @@ dependencies = [ [[package]] name = "typst-html" -version = "0.15.0" +version = "0.15.1" dependencies = [ "az", "bumpalo", @@ -3374,7 +3473,7 @@ dependencies = [ [[package]] name = "typst-ide" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "ecow", @@ -3384,17 +3483,21 @@ dependencies = [ "serde", "typst", "typst-assets", + "typst-bundle", "typst-dev-assets", "typst-eval", "typst-html", "typst-layout", + "typst-pdf", + "typst-render", + "typst-svg", "typst-utils", "unscanny", ] [[package]] name = "typst-ide-wasm" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "ecow", @@ -3411,7 +3514,7 @@ dependencies = [ [[package]] name = "typst-kit" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "codespan-reporting", @@ -3446,7 +3549,7 @@ dependencies = [ [[package]] name = "typst-layout" -version = "0.15.0" +version = "0.15.1" dependencies = [ "az", "bumpalo", @@ -3480,7 +3583,7 @@ dependencies = [ [[package]] name = "typst-library" -version = "0.15.0" +version = "0.15.1" dependencies = [ "arrayvec", "az", @@ -3497,6 +3600,7 @@ dependencies = [ "glidesort", "hayagriva", "hayro-syntax", + "icu_collator", "icu_properties", "image", "indexmap", @@ -3546,17 +3650,17 @@ dependencies = [ [[package]] name = "typst-macros" -version = "0.15.0" +version = "0.15.1" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] name = "typst-pdf" -version = "0.15.0" +version = "0.15.1" dependencies = [ "az", "bytemuck", @@ -3583,10 +3687,11 @@ dependencies = [ [[package]] name = "typst-realize" -version = "0.15.0" +version = "0.15.1" dependencies = [ "arrayvec", "bumpalo", + "codex", "comemo", "ecow", "regex", @@ -3600,7 +3705,7 @@ dependencies = [ [[package]] name = "typst-render" -version = "0.15.0" +version = "0.15.1" dependencies = [ "bytemuck", "comemo", @@ -3615,13 +3720,14 @@ dependencies = [ "typst-layout", "typst-library", "typst-macros", + "typst-syntax", "typst-timing", "typst-utils", ] [[package]] name = "typst-svg" -version = "0.15.0" +version = "0.15.1" dependencies = [ "base64", "comemo", @@ -3639,6 +3745,7 @@ dependencies = [ "typst-layout", "typst-library", "typst-macros", + "typst-syntax", "typst-timing", "typst-utils", "xmlwriter", @@ -3646,7 +3753,7 @@ dependencies = [ [[package]] name = "typst-syntax" -version = "0.15.0" +version = "0.15.1" dependencies = [ "ecow", "rustc-hash", @@ -3663,7 +3770,7 @@ dependencies = [ [[package]] name = "typst-tests" -version = "0.15.0" +version = "0.15.1" dependencies = [ "base64", "bitflags 2.11.1", @@ -3707,7 +3814,7 @@ dependencies = [ [[package]] name = "typst-timing" -version = "0.15.0" +version = "0.15.1" dependencies = [ "parking_lot", "serde", @@ -3717,7 +3824,7 @@ dependencies = [ [[package]] name = "typst-utils" -version = "0.15.0" +version = "0.15.1" dependencies = [ "libm", "once_cell", @@ -3771,7 +3878,7 @@ checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" dependencies = [ "proc-macro-hack", "quote", - "syn", + "syn 2.0.98", "unic-langid-impl", ] @@ -3846,9 +3953,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode_names2" -version = "2.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d189085656ca1203291e965444e7f6a2723fbdd1dd9f34f8482e79bafd8338a0" +checksum = "82c3e18d850bb6ebd57735e5654f0af65e572b05c2397e7b2b1a7c6a792cc29c" dependencies = [ "phf 0.11.3", "unicode_names2_generator", @@ -3856,9 +3963,9 @@ dependencies = [ [[package]] name = "unicode_names2_generator" -version = "2.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1262662dc96937c71115228ce2e1d30f41db71a7a45d3459e98783ef94052214" +checksum = "849744a58c479122ff24910d7ca57f312b62613ef0412dc327776ae6b235d16a" dependencies = [ "phf_codegen", "rand", @@ -3970,9 +4077,8 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vello_common" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +version = "0.0.9" +source = "git+https://github.com/linebender/vello?rev=8442ef4#8442ef44019568a05aeb6a04451e5cf503a6c406" dependencies = [ "bytemuck", "fearless_simd", @@ -3987,9 +4093,8 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +version = "0.0.9" +source = "git+https://github.com/linebender/vello?rev=8442ef4#8442ef44019568a05aeb6a04451e5cf503a6c406" dependencies = [ "bytemuck", "glifo", @@ -4051,7 +4156,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.98", "wasm-bindgen-shared", ] @@ -4073,7 +4178,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -4273,11 +4378,11 @@ version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" dependencies = [ - "font-types", + "font-types 0.11.3", "indexmap", "kurbo", "log", - "read-fonts", + "read-fonts 0.39.2", ] [[package]] @@ -4288,9 +4393,9 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -4308,8 +4413,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e105d177a3871454f754b33bb0ee637ecaaac997446375fd3e5d43a2ed00c909" dependencies = [ "libc", - "linux-raw-sys", - "rustix", + "linux-raw-sys 0.4.15", + "rustix 0.38.44", ] [[package]] @@ -4344,9 +4449,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4361,7 +4466,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", "synstructure", ] @@ -4383,7 +4488,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -4403,15 +4508,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", "synstructure", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "litemap", @@ -4423,9 +4528,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -4435,13 +4540,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 86e72d7415c5..aff6a3a36260 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ default-members = ["crates/typst-cli"] resolver = "2" [workspace.package] -version = "0.15.0" +version = "0.15.1" rust-version = "1.92" # also change in ci.yml authors = ["The Typst Project Developers"] edition = "2024" @@ -16,27 +16,26 @@ keywords = ["typst"] readme = "README.md" [workspace.dependencies] -typst = { path = "crates/typst", version = "0.15.0" } -typst-bundle = { path = "crates/typst-bundle", version = "0.15.0" } -hex = "0.4" +typst = { path = "crates/typst", version = "0.15.1" } +typst-bundle = { path = "crates/typst-bundle", version = "0.15.1" } typst-cnd = { path = "crates/typst-cnd", version = "0.1.0" } -typst-cli = { path = "crates/typst-cli", version = "0.15.0" } -typst-eval = { path = "crates/typst-eval", version = "0.15.0" } -typst-html = { path = "crates/typst-html", version = "0.15.0" } -typst-ide = { path = "crates/typst-ide", version = "0.15.0" } -typst-kit = { path = "crates/typst-kit", version = "0.15.0", default-features = false } -typst-layout = { path = "crates/typst-layout", version = "0.15.0" } -typst-library = { path = "crates/typst-library", version = "0.15.0" } -typst-macros = { path = "crates/typst-macros", version = "0.15.0" } -typst-pdf = { path = "crates/typst-pdf", version = "0.15.0" } -typst-realize = { path = "crates/typst-realize", version = "0.15.0" } -typst-render = { path = "crates/typst-render", version = "0.15.0" } -typst-svg = { path = "crates/typst-svg", version = "0.15.0" } -typst-syntax = { path = "crates/typst-syntax", version = "0.15.0" } -typst-timing = { path = "crates/typst-timing", version = "0.15.0" } -typst-utils = { path = "crates/typst-utils", version = "0.15.0" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "74217e4" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "a2e29ed" } +typst-cli = { path = "crates/typst-cli", version = "0.15.1" } +typst-eval = { path = "crates/typst-eval", version = "0.15.1" } +typst-html = { path = "crates/typst-html", version = "0.15.1" } +typst-ide = { path = "crates/typst-ide", version = "0.15.1" } +typst-kit = { path = "crates/typst-kit", version = "0.15.1", default-features = false } +typst-layout = { path = "crates/typst-layout", version = "0.15.1" } +typst-library = { path = "crates/typst-library", version = "0.15.1" } +typst-macros = { path = "crates/typst-macros", version = "0.15.1" } +typst-pdf = { path = "crates/typst-pdf", version = "0.15.1" } +typst-realize = { path = "crates/typst-realize", version = "0.15.1" } +typst-render = { path = "crates/typst-render", version = "0.15.1" } +typst-svg = { path = "crates/typst-svg", version = "0.15.1" } +typst-syntax = { path = "crates/typst-syntax", version = "0.15.1" } +typst-timing = { path = "crates/typst-timing", version = "0.15.1" } +typst-utils = { path = "crates/typst-utils", version = "0.15.1" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "94dcb99" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "e26d7ca" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" @@ -47,9 +46,10 @@ chrono = { version = "0.4.24", default-features = false, features = ["clock", "s ciborium = "0.2.1" clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } clap_complete = "4.2.1" +clap_complete_nushell = "4.6.2" clap_mangen = { version = "0.3.0", features = ["env"] } codespan-reporting = "0.11" -codex = "0.3.0" +codex = { git = "https://github.com/typst/codex", rev = "2e43ab9" } color-print = "0.3.6" comemo = "0.5.1" csv = "1" @@ -64,11 +64,13 @@ fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" glidesort = "0.1.2" hayagriva = "0.10.1" -hayro = { version = "0.7.1", default-features = false } -hayro-svg = { version = "0.7.0", default-features = false } -hayro-syntax = "0.7.2" +hayro = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608", default-features = false } +hayro-svg = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608", default-features = false } +hayro-syntax = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608" } heck = "0.5" -hypher = "0.1.4" +hex = "0.4" +hypher = "0.1.8" +icu_collator = { version = "2.3.1" } icu_properties = { version = "2.2.0", features = ["serde"] } icu_provider = { version = "2.2.0", features = ["sync"] } icu_provider_blob = "2.2.0" @@ -78,8 +80,8 @@ indexmap = { version = "2", features = ["serde", "rayon"] } infer = { version = "0.19.0", default-features = false } itoa = "1" kamadak-exif = "0.6" -krilla = { version = "0.8.2", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } -krilla-svg = "0.8.1" +krilla = { git = "https://github.com/LaurenzV/krilla", rev = "7772dbe", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "7772dbe" } kurbo = "0.13" libfuzzer-sys = "0.4" libm = "0.2.11" @@ -128,14 +130,14 @@ syntect = { version = "5.3", default-features = false, features = ["parsing", "r tar = "0.4.46" tempfile = "3.7.0" thin-vec = "0.2.18" -time = { version = "0.3.20", features = ["formatting", "macros", "parsing"] } +time = { version = "0.3.55", features = ["formatting", "macros", "parsing"] } tiny_http = "0.12" tiny-skia = "0.12" toml = { version = "0.8", default-features = false, features = ["parse", "display"] } ttf-parser = "0.25.0" two-face = { version = "0.4.3", default-features = false, features = ["syntect-fancy"] } typed-arena = "2" -unicode_names2 = "2" +unicode_names2 = "3.1" unicode-bidi = "0.3.18" unicode-ident = "1.0" unicode-math-class = "0.1" @@ -174,6 +176,7 @@ derived_hash_with_manual_eq = "allow" manual_is_multiple_of = "allow" manual_range_contains = "allow" mutable_key_type = "allow" +single_range_in_vec_init = "allow" wildcard_in_or_patterns = "allow" as_pointer_underscore = "warn" diff --git a/Dockerfile b/Dockerfile index 61dcc87b1b92..bd35bed8d8c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx -FROM --platform=$BUILDPLATFORM rust:alpine AS build +FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0 AS xx +FROM --platform=$BUILDPLATFORM rust:1.98.0-alpine3.23 AS build COPY --from=xx / / @@ -24,7 +24,7 @@ RUN --mount=type=cache,target=/root/.cargo/git/db \ cp target/$(xx-cargo --print-target-triple)/release/typst target/release/typst && \ xx-verify target/release/typst -FROM alpine:latest +FROM alpine:3.23 ARG CREATED ARG REVISION diff --git a/crates/typst-bundle/src/export.rs b/crates/typst-bundle/src/export.rs index 857de0650355..af32467099f0 100644 --- a/crates/typst-bundle/src/export.rs +++ b/crates/typst-bundle/src/export.rs @@ -3,15 +3,18 @@ use ecow::EcoString; use indexmap::IndexMap; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rustc_hash::FxBuildHasher; -use typst_html::{HtmlElement, HtmlOptions}; +use typst_html::{HtmlElement, HtmlFormat, HtmlOptions}; use typst_layout::PagedDocument; -use typst_library::diag::{At, ParallelCollectCombinedResult, SourceResult}; +use typst_library::diag::{ + At, ParallelCollectCombinedWarnedResult, SourceResult, Warned, +}; +use typst_library::format::Complete; use typst_library::foundations::Bytes; use typst_library::introspection::Location; -use typst_library::model::{LateLinkResolver, PagedFormat}; +use typst_library::model::{Document, LateLinkResolver, PagedFormat}; use typst_pdf::PdfOptions; -use typst_render::RenderOptions; -use typst_svg::SvgOptions; +use typst_render::{PngFormat, RenderOptions}; +use typst_svg::{SvgFormat, SvgOptions}; use typst_syntax::{Span, VirtualPath}; use crate::{Bundle, BundleDocument, BundleFile}; @@ -21,7 +24,10 @@ pub type VirtualFs = IndexMap; /// Exports a bundle into a raw virtual file system. #[typst_macros::time(name = "export bundle")] -pub fn export(bundle: &Bundle, options: &BundleOptions) -> SourceResult { +pub fn export( + bundle: &Bundle, + options: &BundleOptions, +) -> Warned> { bundle .files .par_iter() @@ -32,11 +38,11 @@ pub fn export(bundle: &Bundle, options: &BundleOptions) -> SourceResult Ok(bytes.clone()), + BundleFile::Asset(bytes) => Ok(bytes.clone()).into(), }; data.map(|data| (path.clone(), data)) }) - .collect_combined_result() + .collect_combined_warned_result() } /// Settings for bundle export. @@ -57,19 +63,20 @@ fn export_document( doc: &BundleDocument, options: &BundleOptions, link_resolver: Tracked, -) -> SourceResult { +) -> Warned> { match doc { - BundleDocument::Paged(doc, extras) => match extras.format { + BundleDocument::Paged(doc, extras) => match &extras.format { PagedFormat::Pdf => { export_pdf(doc, &options.pdf, &extras.anchors, link_resolver) } - PagedFormat::Png => export_png(doc, &options.png), + PagedFormat::Png => export_png(doc, &options.png).into(), PagedFormat::Svg => { - export_svg(doc, &options.svg, &extras.anchors, link_resolver) + export_svg(doc, &options.svg, &extras.anchors, link_resolver).into() } }, BundleDocument::Html(doc) => { - export_html(doc.root(), &options.html, link_resolver) + let options = options.html.resolve(doc.options().get::()); + export_html(doc.root(), &options, link_resolver).into() } } } @@ -82,7 +89,7 @@ fn export_pdf( options: &PdfOptions, anchors: &[(Location, EcoString)], link_resolver: Tracked, -) -> SourceResult { +) -> Warned> { typst_pdf::pdf_in_bundle(doc, options, anchors, link_resolver).map(Bytes::new) } @@ -90,7 +97,8 @@ fn export_pdf( #[comemo::memoize] #[typst_macros::time(name = "export png")] fn export_png(doc: &PagedDocument, options: &RenderOptions) -> SourceResult { - typst_render::render(&doc.pages()[0], options) + let options = options.resolve(doc.options().get::()); + typst_render::render(&doc.pages()[0], &options) .encode_png() .map(Bytes::new) .map_err(|_| "failed to encode PNG") @@ -116,9 +124,10 @@ fn export_svg( Some((point, name.clone())) }) .collect::>(); + let options = options.resolve(doc.options().get::()); Ok(Bytes::from_string(typst_svg::svg_in_bundle( &doc.pages()[0], - options, + &options, &anchors, link_resolver, ))) @@ -135,7 +144,7 @@ fn export_svg( #[typst_macros::time(name = "export html")] fn export_html( root: &HtmlElement, - options: &HtmlOptions, + options: &HtmlOptions, link_resolver: Tracked, ) -> SourceResult { typst_html::html_in_bundle(root, options, link_resolver).map(Bytes::from_string) diff --git a/crates/typst-bundle/src/format.rs b/crates/typst-bundle/src/format.rs new file mode 100644 index 000000000000..a710e59d1bbc --- /dev/null +++ b/crates/typst-bundle/src/format.rs @@ -0,0 +1,245 @@ +use typst_library::Feature; +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::Engine; +use typst_library::format::{Format, FormatElement}; +use typst_library::foundations::{ + Args, BundlePath, Bytes, Construct, Content, NativeRuleMap, ShowFn, Str, Target, + cast, elem, scope, +}; +use typst_library::model::DocumentElem; + +/// The format element for registering the bundle format. +pub const FORMAT: Format = Format::new::() + .with_feature(Feature::Bundle) + .with_rules(register); + +/// Registers show rules for bundle specific elements. +pub fn register(rules: &mut NativeRuleMap) { + for target in [Target::Paged, Target::Html] { + rules.replace(target, DOCUMENT_UNSUPPORTED_RULE); + rules.register(target, ASSET_UNSUPPORTED_RULE); + } +} + +/// Typst's bundle export format (experimental). +/// +/// #info[ +/// Bundle export is only available for experimentation behind a feature flag. +/// Do not use this feature for production use cases. In the CLI, you can +/// experiment with it by passing `--features bundle` or setting the +/// `TYPST_FEATURES` environment variable to `bundle`. To use both the +/// `bundle` and the `html` feature at the same time, specify both separated +/// with a comma (i.e. `bundle,html`). In the web app, bundle export is not +/// available at this time. +/// ] +/// +/// With Typst's bundle export, you can emit multiple output files from a single +/// Typst project. Bundle output is useful for creating multi-page websites with +/// HTML export, but it is not limited to HTML export. You can create bundles +/// containing any combination of @html[HTML pages], @pdf[PDFs], +/// @format.png[PNGs], @format.svg[SVGs], and arbitrary @asset[assets]. +/// +/// = Exporting as a bundle +/// == Command Line +/// Pass `--format bundle` to the `compile` or `watch` subcommand. Note that you +/// must also pass `--features bundle` or set `TYPST_FEATURES=bundle` to enable +/// this experimental export target. +/// +/// When using `typst watch`, Typst will launch a live-reloading HTTP server +/// serving your files. You can configure it as follows: +/// +/// - Pass `--port` to change the port. (Defaults to the first free port in the +/// range 3000-3005.) +/// - Pass `--no-reload` to disable injection of a live reload script into HTML +/// pages. (The HTML that is written to disk isn't affected either way.) +/// Non-HTML documents do not support live reload. +/// - Pass `--no-serve` to disable the server altogether. +/// +/// == Web App +/// Not currently available. +/// +/// = Creating files +/// A bundle is a collection of files. Each of these bundle files falls into one +/// of two categories: Document or asset. A @document takes @content[content] +/// that is exported with one of Typst's other export formats. Meanwhile, an +/// @asset takes raw @bytes[byte data] of your choice that will be written to +/// disk as-is. Both elements take the desired output path as their first +/// argument. +/// +/// The example below shows a basic example of how bundle export could be used +/// in practice: +/// +/// ```typ +/// #document("index.html", title: [Home])[ +/// #title() +/// - #link()[Go to blog] +/// ] +/// +/// #document("blog.html", title: [Blog])[ +/// #title() +/// Welcome to my blog! +/// +/// ... +/// +/// This blog also exists as a +/// #link()[single PDF]. +/// ] +/// +/// #document("blog.pdf", title: [Blog])[ +/// ... +/// ] +/// +/// #asset( +/// "favicon.ico", +/// read("images/favicon.ico", encoding: none), +/// ) +/// ``` +/// +/// In the example, we create two HTML documents: A home page and a blog. The +/// home page links to the blog through a label link. Typst's built-in linking +/// mechanism natively supports @link:links-in-bundle-export[cross-document +/// links] and resolves the correct relative paths for you. The bundle also +/// contains a PDF version of the blog, which is linked from the HTML version. +/// In practice, you could now share the content between the HTML and PDF +/// version by storing it in a variable and using it in both. This is omitted +/// here for brevity. Finally, the bundle contains an icon asset for the +/// website. In this case, we're providing the asset's data by reading a file +/// from disk. Alternatively, it's also possible to generate asset data from +/// within Typst (e.g. via a function like @json.encode). +/// +/// Documents and assets are normal elements, so you can use them with Typst's +/// usual scripting, styling, and introspection mechanisms. For more details, +/// refer to the @document and @asset documentation. +/// +/// = Introspection +/// Introspections always observe the full bundle rather than individual +/// documents. For instance, querying for headings will give you all headings in +/// all documents rather than the ones in the current document. Similarly, +/// labels are global to the bundle—you can locate and +/// @link:links-in-bundle-export[link to labels in other documents]. Counters +/// and states are likewise global. #footnote[An exception to this forms the +/// page counter, which is (naturally) per document.] In particular, if you +/// enable things like heading numbering, the numbering will progress +/// consecutively across the full bundle. +/// +/// If you're using bundle export to build one conceptual work that is split up +/// across multiple output files, this is typically what you want. If, however, +/// you're using bundle export to export multiple conceptually separate works, +/// you might want introspections to consider each document in isolation. +/// Currently, you'll have to do this manually (e.g. by resetting counters, +/// adjusting selectors, etc.) We +/// #link("https://github.com/typst/typst/issues/7735#issuecomment-3908841853")[plan +/// to provide more tools] for managing the precise scope of introspection in +/// the future. +#[elem(scope, name = "bundle", since = "unreleased", Construct)] +pub struct BundleFormat {} + +impl Construct for BundleFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +impl FormatElement for BundleFormat { + type Options = (); +} + +#[scope(category = Bundle)] +impl BundleFormat { + #[elem] + type AssetElem; +} + +pub const DOCUMENT_UNSUPPORTED_RULE: ShowFn = |elem, _, _| { + bail!( + elem.span(), + "constructing a document is only supported in the bundle target"; + // TODO: Support for CLI-specific hints would be nice. + hint: "try enabling the bundle target"; + hint: "or use a `set document(..)` rule to configure metadata"; + ) +}; + +/// Adds a custom file to a bundle. +/// +/// This function creates a single file in a @format.bundle[bundle], from +/// @bytes[raw byte data]. Unlike @document[documents], assets will be emitted +/// as-is without undergoing compilation. +/// +/// The `asset` function can be combined with @read to copy a file from the +/// project into the output bundle. The first argument to `asset` defines the +/// output path for the asset in the bundle, while the path passed to `read` +/// defines where in the project to read the data from. +/// +/// ```typ +/// // Copy the file `styles.css` into the bundle. +/// #asset("styles.css", read("styles.css")) +/// ``` +/// +/// That said, `asset` is not tied to `read`. You can also generate bytes +/// directly or use a function like @json.encode to emit serialized data. +/// +/// ```typ +/// // Emits a JSON file with the number +/// // of headings in the document. +/// #context { +/// let headings = query(heading) +/// let meta = ( +/// count: headings.len(), +/// ) +/// asset("meta.json", json.encode(meta)) +/// } +/// +/// #document("doc.pdf")[ +/// = Introduction +/// = Conclusion +/// ] +/// ``` +/// +/// This would emit a `meta.json` file with the following contents into the +/// resulting bundle: +/// +/// ```json +/// { +/// "count": 2 +/// } +/// ``` +/// +/// This function is defined on the @format.bundle element, but is also +/// available directly in the global scope. It may only be used in the +/// @format.bundle[bundle] target. +#[elem(since = "0.15.0", Locatable)] +pub struct AssetElem { + /// The path in the bundle at which the asset will be placed. + /// + /// May contain interior slashes, in which case intermediate directories + /// will be automatically created. + #[required] + pub path: BundlePath, + + /// The raw data that will be written into the file at the specified path. + /// + /// If a string is given, it will be encoded using UTF-8. + #[required] + pub data: AssetData, +} + +pub const ASSET_UNSUPPORTED_RULE: ShowFn = |elem, _, _| { + bail!( + elem.span(), + "assets are only supported in the bundle target"; + // TODO: Support for CLI-specific hints would be nice. + hint: "try enabling the bundle target"; + ) +}; + +/// The raw data for an asset. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct AssetData(pub Bytes); + +cast! { + AssetData, + self => self.0.into_value(), + v: Str => Self(Bytes::from_string(v)), + v: Bytes => Self(v), +} diff --git a/crates/typst-bundle/src/introspect.rs b/crates/typst-bundle/src/introspect.rs index badd455bebd0..729accf00cd2 100644 --- a/crates/typst-bundle/src/introspect.rs +++ b/crates/typst-bundle/src/introspect.rs @@ -13,9 +13,10 @@ use typst_library::introspection::{ DocumentPosition, ElementIntrospector, ElementIntrospectorBuilder, Introspector, Location, }; -use typst_library::model::{AssetElem, DocumentElem, LinkElem, Numbering}; +use typst_library::model::{DocumentElem, LinkElem, Numbering}; use typst_syntax::VirtualPath; +use crate::format::AssetElem; use crate::{BundleDocument, Item}; /// An introspector implementation for bundles. diff --git a/crates/typst-bundle/src/lib.rs b/crates/typst-bundle/src/lib.rs index 02a159102ab9..7ee8d44444f3 100644 --- a/crates/typst-bundle/src/lib.rs +++ b/crates/typst-bundle/src/lib.rs @@ -2,12 +2,12 @@ #[path = "export.rs"] mod export_; +mod format; mod introspect; mod link; -use crate::introspect::BundleIntrospector; - pub use self::export_::{BundleOptions, VirtualFs, export}; +pub use self::format::{AssetData, AssetElem, BundleFormat, FORMAT}; use std::collections::hash_map::Entry; use std::sync::Arc; @@ -20,6 +20,7 @@ use typst_html::HtmlDocument; use typst_layout::PagedDocument; use typst_library::diag::{At, CollectCombinedResult, SourceResult, bail, error}; use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{ Bytes, Content, Output, Packed, StyleChain, Target, TargetElem, }; @@ -27,13 +28,15 @@ use typst_library::introspection::{ Introspector, Location, Locator, SplitLocator, Tag, TagElem, }; use typst_library::model::{ - AssetElem, Document, DocumentElem, DocumentFormat, DocumentInfo, PagedFormat, + Document, DocumentElem, DocumentFormat, DocumentInfo, PagedFormat, }; use typst_library::routines::{Arenas, Pair, RealizationKind}; use typst_library::{Feature, Library, World}; use typst_syntax::VirtualPath; use typst_utils::{LazyHash, Protected}; +use crate::introspect::BundleIntrospector; + /// A collection of files resulting from compilation. /// /// In the `bundle` target, Typst can emit multiple documents and assets from a @@ -98,6 +101,13 @@ impl Document for BundleDocument { BundleDocument::Html(doc) => doc.info(), } } + + fn options(&self) -> &DocumentFormatOptions { + match self { + BundleDocument::Paged(doc, _) => doc.options(), + BundleDocument::Html(doc) => doc.options(), + } + } } /// Extra data relevant for exporting a paged document in a bundle. @@ -305,12 +315,12 @@ fn compile_document<'a>( let num_pages = doc.pages().len(); if num_pages != 1 && matches!(format, PagedFormat::Png | PagedFormat::Svg) { - bail!( + engine.sink.delayed_error(error!( document.span(), "expected document to have a single page"; hint: "the document resulted in {num_pages} pages"; hint: "documents exported to an image format only support a single page"; - ); + )); } BundleDocument::Paged( diff --git a/crates/typst-bundle/src/link.rs b/crates/typst-bundle/src/link.rs index 7778340fe09e..a6ef21679285 100644 --- a/crates/typst-bundle/src/link.rs +++ b/crates/typst-bundle/src/link.rs @@ -38,11 +38,11 @@ pub fn create_link_anchors( ) .into_iter(), ), - BundleDocument::Paged(doc, options) => { + BundleDocument::Paged(doc, extras) => { // Mutates the export options so that named destinations // are generated (if supported by the format). - options.anchors = create_paged_link_anchors(doc, targets); - Either::Right(options.anchors.iter().cloned()) + extras.anchors = create_paged_link_anchors(doc, targets); + Either::Right(extras.anchors.iter().cloned()) } } }) diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index 2211746f8e77..7d9bab8d1763 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -32,6 +32,7 @@ typst-utils = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["string"] } clap_complete = { workspace = true } +clap_complete_nushell = { workspace = true } codespan-reporting = { workspace = true } color-print = { workspace = true } comemo = { workspace = true } @@ -79,6 +80,7 @@ tempfile = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["string"] } clap_complete = { workspace = true } +clap_complete_nushell = { workspace = true } clap_mangen = { workspace = true } color-print = { workspace = true } semver = { workspace = true } diff --git a/crates/typst-cli/build.rs b/crates/typst-cli/build.rs index ac18582adddc..4301177cc855 100644 --- a/crates/typst-cli/build.rs +++ b/crates/typst-cli/build.rs @@ -3,7 +3,7 @@ use std::fs::{File, create_dir_all}; use std::path::Path; use clap::{CommandFactory, ValueEnum}; -use clap_complete::{Shell, generate_to}; +use clap_complete::generate_to; use clap_mangen::Man; #[path = "src/args.rs"] @@ -31,7 +31,7 @@ fn main() { .unwrap(); } - for shell in Shell::value_variants() { + for shell in args::Shell::value_variants() { generate_to(*shell, cmd, "typst", out).unwrap(); } } diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 154200d3f89f..2429c4f46d99 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -13,7 +13,7 @@ use std::str::FromStr; use clap::builder::styling::{AnsiColor, Effects}; use clap::builder::{Styles, TypedValueParser, ValueParser}; use clap::{ArgAction, Args, ColorChoice, Parser, Subcommand, ValueEnum, ValueHint}; -use clap_complete::Shell; +use clap_complete::Generator; use semver::Version; use serde::Serialize; use typst_utils::display_possible_values; @@ -66,7 +66,7 @@ pub struct CliArguments { #[command(subcommand)] pub command: Command, - /// Whether to use color. When set to `auto` if the terminal to supports it. + /// Whether to use color. When set to `auto`, uses color if the terminal supports it. #[clap(long, default_value_t = ColorChoice::Auto, default_missing_value = "always")] pub color: ColorChoice, @@ -126,6 +126,13 @@ pub struct WatchCommand { #[clap(flatten)] pub args: CompileArgs, + /// Stops the watcher from taking over the terminal. + /// + /// With this flag, the watcher will only ever print normal output lines, + /// not clear or reconfigure the terminal. + #[arg(long)] + pub no_fullscreen: bool, + /// Arguments for the HTTP server. #[cfg(feature = "http-server")] #[clap(flatten)] @@ -209,7 +216,7 @@ pub struct EvalCommand { /// The format to serialize in. #[clap(long = "format", default_value_t)] - pub format: SerializationFormat, + pub format: EvalSerializationFormat, /// Whether to pretty-print the serialized output. /// @@ -272,6 +279,43 @@ pub struct CompletionsCommand { pub shell: Shell, } +/// Which shell to generate completions for. +#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +#[expect(clippy::enum_variant_names)] +pub enum Shell { + Bash, + Elvish, + Fish, + Nushell, + PowerShell, + Zsh, +} + +impl Generator for Shell { + fn file_name(&self, name: &str) -> String { + match self { + Shell::Bash => clap_complete::shells::Bash.file_name(name), + Shell::Elvish => clap_complete::shells::Elvish.file_name(name), + Shell::Fish => clap_complete::shells::Fish.file_name(name), + Shell::Nushell => clap_complete_nushell::Nushell.file_name(name), + Shell::PowerShell => clap_complete::shells::PowerShell.file_name(name), + Shell::Zsh => clap_complete::shells::Zsh.file_name(name), + } + } + + fn generate(&self, cmd: &clap::Command, buf: &mut dyn Write) { + match self { + Shell::Bash => clap_complete::shells::Bash.generate(cmd, buf), + Shell::Elvish => clap_complete::shells::Elvish.generate(cmd, buf), + Shell::Fish => clap_complete::shells::Fish.generate(cmd, buf), + Shell::Nushell => clap_complete_nushell::Nushell.generate(cmd, buf), + Shell::PowerShell => clap_complete::shells::PowerShell.generate(cmd, buf), + Shell::Zsh => clap_complete::shells::Zsh.generate(cmd, buf), + } + } +} + /// Displays environment variables and default values Typst uses. #[derive(Debug, Clone, Parser)] pub struct InfoCommand { @@ -324,8 +368,13 @@ pub struct CompileArgs { /// This formats the output in a more human-readable, but less /// space-efficient way. Affects HTML, SVG, and PDF export, but not PNG /// export. - #[arg(long = "pretty")] - pub pretty: bool, + #[arg( + long = "pretty", + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + )] + pub pretty: Option, /// Which pages to export. When unspecified, all pages are exported. /// @@ -349,12 +398,29 @@ pub struct CompileArgs { /// document is written to provide a baseline of accessibility. In some /// circumstances (for example when trying to reduce the size of a document) /// it can be desirable to disable tagged PDF. - #[arg(long = "no-pdf-tags")] + // TODO: Remove deprecated flag in the 0.17 release cycle. + #[arg(long = "no-pdf-tags", hide = true)] pub no_pdf_tags: bool, + /// Enables or disables PDF tagging. + /// + /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF + /// document is written to provide a baseline of accessibility. In some + /// circumstances (for example when trying to reduce the size of a document) + /// it can be desirable to disable PDF tags. + #[arg( + long = "pdf-tagged", + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + )] + pub pdf_tagged: Option, + /// The PPI (pixels per inch) to use for PNG export. - #[arg(long = "ppi", default_value_t = 144.0)] - pub ppi: f64, + /// + /// [default: 144] + #[arg(long = "ppi")] + pub ppi: Option, /// File path to which a Makefile with the current compilation's /// dependencies will be written. @@ -381,7 +447,10 @@ pub struct CompileArgs { /// Opens the output file with the default viewer or a specific program /// after compilation. Ignored if output is stdout. - #[arg(long = "open", value_name = "VIEWER")] + /// + /// When passing a specific program, the name must be attached with + /// an equals sign (`--open=VIEWER`). + #[arg(long = "open", value_name = "VIEWER", require_equals = true)] pub open: Option>, /// Produces performance timings of the compilation process. (experimental) @@ -403,6 +472,7 @@ pub struct WorldArgs { /// Add a string key-value pair visible through `sys.inputs`. #[clap( + short = 'i', long = "input", value_name = "key=value", action = ArgAction::Append, @@ -443,7 +513,7 @@ pub struct ProcessArgs { pub features: Vec, /// The format to emit diagnostics in. - #[clap(long, default_value_t)] + #[clap(long, default_value_t, env = "TYPST_DIAGNOSTIC_FORMAT")] pub diagnostic_format: DiagnosticFormat, } @@ -722,6 +792,19 @@ pub enum SerializationFormat { display_possible_values!(SerializationFormat); +/// Output file format for eval command +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, ValueEnum)] +pub enum EvalSerializationFormat { + #[default] + Json, + Yaml, + /// Prints the output without any additional formatting or escaping + /// (only supports strings and bytes). + Raw, +} + +display_possible_values!(EvalSerializationFormat); + /// Implements parsing of page ranges (`1-3`, `4`, `5-`, `-2`), used by the /// `CompileCommand.pages` argument, through the `FromStr` trait instead of a /// value parser, in order to generate better errors. @@ -735,24 +818,24 @@ impl FromStr for Pages { fn from_str(value: &str) -> Result { match value.split('-').map(str::trim).collect::>().as_slice() { - [] | [""] => Err("page export range must not be empty"), + [] | [""] => Err("page range must not be empty"), [single_page] => { let page_number = parse_page_number(single_page)?; Ok(Pages(Some(page_number)..=Some(page_number))) } - ["", ""] => Err("page export range must have start or end"), + ["", ""] => Err("page range must have start or end"), [start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)), ["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))), [start, end] => { let start = parse_page_number(start)?; let end = parse_page_number(end)?; if start > end { - Err("page export range must end at a page after the start") + Err("page range must end at a page after the start") } else { Ok(Pages(Some(start)..=Some(end))) } } - [_, _, _, ..] => Err("page export range must have a single hyphen"), + [_, _, _, ..] => Err("page range must have a single hyphen"), } } } diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 71d9091added..64760061e341 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -10,21 +10,22 @@ use typst::diag::{ bail, }; use typst::foundations::{Datetime, Smart}; -use typst::layout::PageRanges; +use typst::layout::{PageRange, PageRanges}; +use typst::model::Document; use typst::syntax::Span; use typst_bundle::{Bundle, BundleOptions, VirtualFs}; -use typst_html::{HtmlDocument, HtmlOptions}; +use typst_html::{HtmlDocument, HtmlFormatOptions, HtmlOptions}; use typst_kit::diagnostics::DiagnosticWorld; use typst_kit::timer::Timer; use typst_layout::{Page, PagedDocument}; -use typst_pdf::{PdfOptions, PdfStandards, Timestamp}; -use typst_render::RenderOptions; -use typst_svg::SvgOptions; +use typst_pdf::{PdfFormatOptions, PdfOptions, PdfStandards, Timestamp}; +use typst_render::{PngFormat, PngFormatOptions, RenderOptions}; +use typst_svg::{SvgFormat, SvgFormatOptions, SvgOptions}; use typst_utils::Scalar; use crate::args::{ - CompileArgs, CompileCommand, DepsFormat, DiagnosticFormat, Input, Output, - OutputFormat, PdfStandard, WatchCommand, + self, CompileArgs, CompileCommand, DepsFormat, DiagnosticFormat, Input, Output, + OutputFormat, WatchCommand, }; use crate::deps::write_deps; use crate::watch::Status; @@ -53,6 +54,8 @@ pub struct CompileConfig { pub warnings: Vec, /// Whether we are watching. pub watching: bool, + /// Whether to clear the terminal on recompilation (only while watching). + pub fullscreen: bool, /// Path to input Typst file or stdin. pub input: Input, /// Path to output file (PDF, PNG, SVG, or HTML). @@ -60,7 +63,7 @@ pub struct CompileConfig { /// The format of the output file. pub output_format: OutputFormat, /// Whether to make the serialized document pretty. - pub pretty: bool, + pub pretty: Option, /// Which pages to export. pub pages: Option, /// The document's creation date formatted as a UNIX timestamp, with UTC suffix. @@ -71,15 +74,15 @@ pub struct CompileConfig { /// compilation. pub open: Option>, /// A list of standards the PDF should conform to. - pub pdf_standards: PdfStandards, + pub pdf_standards: Option, /// Whether to write PDF (accessibility) tags. - pub tagged: bool, + pub tagged: Option, /// A destination to write a list of dependencies to. pub deps: Option, /// The format to use for dependencies. pub deps_format: DepsFormat, /// The PPI (pixels per inch) to use for PNG export. - pub ppi: f64, + pub ppi: Option, /// The export cache for images, used for caching output files in `typst /// watch` sessions with images. pub export_cache: ExportCache, @@ -142,25 +145,46 @@ impl CompileConfig { }); let pages = args.pages.as_ref().map(|export_ranges| { - PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect()) + PageRanges::new( + export_ranges + .iter() + .map(|r| PageRange::new(*r.0.start(), *r.0.end())) + .collect(), + ) }); - let tagged = !args.no_pdf_tags && pages.is_none(); - if output_format == OutputFormat::Pdf && pages.is_some() && !args.no_pdf_tags { + if args.no_pdf_tags { warnings.push( - HintedString::from("using --pages implies --no-pdf-tags").with_hints([ - "the resulting PDF will be inaccessible".into(), - "add --no-pdf-tags to silence this warning".into(), - ]), + "`--no-pdf-tags` is deprecated, use `--pdf-tagged=false` instead".into(), ); } - if !tagged { - const ACCESSIBLE: &[(PdfStandard, &str)] = &[ - (PdfStandard::A_1a, "PDF/A-1a"), - (PdfStandard::A_2a, "PDF/A-2a"), - (PdfStandard::A_3a, "PDF/A-3a"), - (PdfStandard::UA_1, "PDF/UA-1"), + let tagged = if let Some(tagged) = args.pdf_tagged { + if tagged && pages.is_some() { + bail!("cannot enable PDF tags when exporting a page range"); + } + Some(tagged) + } else { + if output_format == OutputFormat::Pdf && pages.is_some() && !args.no_pdf_tags + { + warnings.push( + HintedString::from("using `--pages` implies `--pdf-tagged=false`") + .with_hints([ + "the resulting PDF will be inaccessible".into(), + "add `--pdf-tagged=false` to silence this warning".into(), + ]), + ); + } + + (args.no_pdf_tags || pages.is_some()).then_some(false) + }; + + if tagged == Some(false) { + const ACCESSIBLE: &[(args::PdfStandard, &str)] = &[ + (args::PdfStandard::A_1a, "PDF/A-1a"), + (args::PdfStandard::A_2a, "PDF/A-2a"), + (args::PdfStandard::A_3a, "PDF/A-3a"), + (args::PdfStandard::UA_1, "PDF/UA-1"), ]; for (standard, name) in ACCESSIBLE { @@ -168,18 +192,21 @@ impl CompileConfig { if args.no_pdf_tags { bail!("cannot disable PDF tags when exporting a {name} document"); } else { - bail!( - "cannot disable PDF tags when exporting a {name} document"; - hint: "using --pages implies --no-pdf-tags"; - ); + let mut err = HintedString::from(eco_format!( + "cannot disable PDF tags when exporting a {name} document", + )); + if args.pages.is_some() { + err.hint("using --pages implies --pdf-tagged=false"); + } + return Err(err); } } } } - let pdf_standards = PdfStandards::new( - &args.pdf_standard.iter().copied().map(Into::into).collect::>(), - )?; + let pdf_standards = (!args.pdf_standard.is_empty()) + .then(|| PdfStandards::new(args.pdf_standard.iter().copied())) + .transpose()?; #[cfg(feature = "http-server")] let server = if let Some(command) = watch @@ -195,6 +222,8 @@ impl CompileConfig { None }; + let fullscreen = watch.map(|command| !command.no_fullscreen).unwrap_or(true); + let mut deps = args.deps.clone(); let mut deps_format = args.deps_format; @@ -247,6 +276,7 @@ impl CompileConfig { deps_format, #[cfg(feature = "http-server")] server, + fullscreen, }) } } @@ -320,9 +350,8 @@ fn compile_and_export( ) -> Warned>> { match config.output_format { OutputFormat::Pdf | OutputFormat::Png | OutputFormat::Svg => { - let Warned { output, warnings } = typst::compile::(world); - let result = output.and_then(|document| export_paged(&document, config)); - Warned { output: result, warnings } + typst::compile::(world) + .and_then(|document| export_paged(&document, config)) } OutputFormat::Html => { let Warned { output, warnings } = typst::compile::(world); @@ -332,17 +361,14 @@ fn compile_and_export( warnings, } } - OutputFormat::Bundle => { - let Warned { output, warnings } = typst::compile::(world); - let result = output.and_then(|bundle| export_bundle(bundle, config)); - Warned { output: result, warnings } - } + OutputFormat::Bundle => typst::compile::(world) + .and_then(|bundle| export_bundle(bundle, config)), } } /// Export to HTML. fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult<()> { - let options = HtmlOptions { pretty: config.pretty }; + let options = html_options(config); let html = typst_html::html(document, &options)?; let result = config.output.write(html.as_bytes()); @@ -360,35 +386,42 @@ fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult< fn export_paged( document: &PagedDocument, config: &CompileConfig, -) -> SourceResult> { +) -> Warned>> { match config.output_format { OutputFormat::Pdf => { - export_pdf(document, config).map(|()| vec![config.output.clone()]) - } - OutputFormat::Png => { - export_image(document, config, ImageExportFormat::Png).at(Span::detached()) - } - OutputFormat::Svg => { - export_image(document, config, ImageExportFormat::Svg).at(Span::detached()) + export_pdf(document, config).and_then(|()| Ok(vec![config.output.clone()])) } + OutputFormat::Png => export_image(document, config, ImageExportFormat::Png) + .at(Span::detached()) + .into(), + OutputFormat::Svg => export_image(document, config, ImageExportFormat::Svg) + .at(Span::detached()) + .into(), OutputFormat::Html | OutputFormat::Bundle => unreachable!(), } } /// Export to a PDF. -fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> { +fn export_pdf( + document: &PagedDocument, + config: &CompileConfig, +) -> Warned> { let options = pdf_options(config); - let buffer = typst_pdf::pdf(document, &options)?; - config - .output - .write(&buffer) - .map_err(|err| eco_format!("failed to write PDF file ({err})")) - .at(Span::detached())?; - Ok(()) + typst_pdf::pdf(document, &options).and_then(|buffer| { + config + .output + .write(&buffer) + .map_err(|err| eco_format!("failed to write PDF file ({err})")) + .at(Span::detached())?; + Ok(()) + }) } /// Export to a bundle, a collection of files in a directory. -fn export_bundle(bundle: Bundle, config: &CompileConfig) -> SourceResult> { +fn export_bundle( + bundle: Bundle, + config: &CompileConfig, +) -> Warned>> { let options = BundleOptions { html: html_options(config), pdf: pdf_options(config), @@ -396,22 +429,23 @@ fn export_bundle(bundle: Bundle, config: &CompileConfig) -> SourceResult path, - Output::Stdout => { - bail!(Span::detached(), "cannot write bundle to standard output") - } - }; + typst_bundle::export(&bundle, &options).and_then(|fs| { + let root = match &config.output { + Output::Path(path) => path, + Output::Stdout => { + bail!(Span::detached(), "cannot write bundle to standard output") + } + }; - let outputs = write_virtual_fs(root, &fs).at(Span::detached())?; + let outputs = write_virtual_fs(root, &fs).at(Span::detached())?; - #[cfg(feature = "http-server")] - if let Some(server) = &config.server { - server.set_bundle(bundle, fs); - } + #[cfg(feature = "http-server")] + if let Some(server) = &config.server { + server.set_bundle(bundle, fs); + } - Ok(outputs) + Ok(outputs) + }) } /// Writes a bundle's files to disk. @@ -464,6 +498,46 @@ fn export_image( config: &CompileConfig, fmt: ImageExportFormat, ) -> StrResult> { + match fmt { + ImageExportFormat::Png => { + let options = + png_options(config).resolve(document.options().get::()); + export_image_pages(config, document, |page, output| { + let pixmap = typst_render::render(page, &options); + let buf = pixmap + .encode_png() + .map_err(|err| eco_format!("failed to encode PNG file ({err})"))?; + output + .write(&buf) + .map_err(|err| eco_format!("failed to write PNG file ({err})"))?; + Ok(()) + }) + } + ImageExportFormat::Svg => { + let options = + svg_options(config).resolve(document.options().get::()); + export_image_pages(config, document, |page, output| { + let svg = typst_svg::svg(page, &options); + output + .write(svg.as_bytes()) + .map_err(|err| eco_format!("failed to write SVG file ({err})"))?; + Ok(()) + }) + } + } +} + +/// Exports the document's pages as separate images, each exported with a +/// format-specific `export_image_page` function. +fn export_image_pages( + config: &CompileConfig, + document: &PagedDocument, + export_image_page: F, +) -> StrResult> +where + F: Fn(&Page, &Output) -> StrResult<()>, + F: Send + Sync, +{ // Determine whether we have indexable templates in output let can_handle_multiple = match config.output { Output::Stdout => false, @@ -519,7 +593,7 @@ fn export_image( && config.export_cache.is_cached(*i, page) && path.exists() { - return Ok(Output::Path(path.to_path_buf())); + return Ok(Output::Path(path.to_owned())); } Output::Path(path.to_owned()) @@ -527,7 +601,8 @@ fn export_image( Output::Stdout => Output::Stdout, }; - export_image_page(config, page, &output, fmt)?; + export_image_page(page, &output)?; + Ok(output) }) .collect::>>() @@ -562,38 +637,11 @@ mod output_template { } } -/// Export single image. -fn export_image_page( - config: &CompileConfig, - page: &Page, - output: &Output, - fmt: ImageExportFormat, -) -> StrResult<()> { - match fmt { - ImageExportFormat::Png => { - let options = png_options(config); - let pixmap = typst_render::render(page, &options); - let buf = pixmap - .encode_png() - .map_err(|err| eco_format!("failed to encode PNG file ({err})"))?; - output - .write(&buf) - .map_err(|err| eco_format!("failed to write PNG file ({err})"))?; - } - ImageExportFormat::Svg => { - let options = svg_options(config); - let svg = typst_svg::svg(page, &options); - output - .write(svg.as_bytes()) - .map_err(|err| eco_format!("failed to write SVG file ({err})"))?; - } - } - Ok(()) -} - /// Creates options for HTML export. fn html_options(config: &CompileConfig) -> HtmlOptions { - HtmlOptions { pretty: config.pretty } + HtmlOptions { + format: HtmlFormatOptions { pretty: config.pretty }, + } } /// Creates options for PDF export. @@ -617,23 +665,30 @@ fn pdf_options(config: &CompileConfig) -> PdfOptions { ident: Smart::Auto, creator: Smart::Auto, timestamp, - page_ranges: config.pages.clone(), - standards: config.pdf_standards.clone(), - tagged: config.tagged, - pretty: config.pretty, + format: PdfFormatOptions { + pages: config.pages.clone().map(Some), + standard: config.pdf_standards.clone(), + tagged: config.tagged.map(Smart::Custom), + pretty: config.pretty, + }, } } /// Creates options for SVG export. fn svg_options(config: &CompileConfig) -> SvgOptions { - SvgOptions { render_bleed: false, pretty: config.pretty } + SvgOptions { + render_bleed: false, + format: SvgFormatOptions { pretty: config.pretty }, + } } /// Creates options for PNG export. fn png_options(config: &CompileConfig) -> RenderOptions { RenderOptions { - pixel_per_pt: Scalar::new(config.ppi / 72.0), render_bleed: false, + format: PngFormatOptions { + pixel_per_pt: config.ppi.map(|ppi| Scalar::new(ppi / 72.0)), + }, } } @@ -732,26 +787,26 @@ pub fn print_diagnostics( ) } -impl From for typst_pdf::PdfStandard { - fn from(standard: PdfStandard) -> Self { +impl From for typst_pdf::PdfStandard { + fn from(standard: args::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_1a => typst_pdf::PdfStandard::A_1a, - PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, - PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u, - PdfStandard::A_2a => typst_pdf::PdfStandard::A_2a, - PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, - PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u, - PdfStandard::A_3a => typst_pdf::PdfStandard::A_3a, - PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, - PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, - PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, - PdfStandard::UA_1 => typst_pdf::PdfStandard::Ua_1, + args::PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4, + args::PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5, + args::PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6, + args::PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, + args::PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0, + args::PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b, + args::PdfStandard::A_1a => typst_pdf::PdfStandard::A_1a, + args::PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, + args::PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u, + args::PdfStandard::A_2a => typst_pdf::PdfStandard::A_2a, + args::PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, + args::PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u, + args::PdfStandard::A_3a => typst_pdf::PdfStandard::A_3a, + args::PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, + args::PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, + args::PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, + args::PdfStandard::UA_1 => typst_pdf::PdfStandard::UA_1, } } } diff --git a/crates/typst-cli/src/eval.rs b/crates/typst-cli/src/eval.rs index 53d340086111..ca6dcd61ce14 100644 --- a/crates/typst-cli/src/eval.rs +++ b/crates/typst-cli/src/eval.rs @@ -1,9 +1,10 @@ +use std::io::{Write, stdout}; use std::sync::LazyLock; use comemo::Track; use ecow::eco_format; use typst::Library; -use typst::diag::{FileResult, HintedStrResult, SourceResult, Warned}; +use typst::diag::{FileResult, HintedStrResult, SourceResult, Warned, bail}; use typst::foundations::{ Bytes, Context, Datetime, Duration, Output, Scope, StyleChain, Value, }; @@ -20,7 +21,7 @@ use typst_kit::diagnostics::DiagnosticWorld; use typst_layout::PagedDocument; use typst_utils::LazyHash; -use crate::args::{EvalCommand, Target}; +use crate::args::{EvalCommand, EvalSerializationFormat, SerializationFormat, Target}; use crate::compile::print_diagnostics; use crate::set_failed; use crate::world::SystemWorld; @@ -38,11 +39,11 @@ pub fn eval(command: &'static EvalCommand) -> HintedStrResult<()> { // Compile the main file and get the introspector. let Warned { output, mut warnings } = match command.target { Target::Paged => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), Target::Html => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), Target::Bundle => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), }; match output { @@ -61,11 +62,12 @@ pub fn eval(command: &'static EvalCommand) -> HintedStrResult<()> { output.introspector(), ); let errors = match &eval_result { - Err(errors) => errors.as_slice(), + Err(errors) => { + set_failed(); + errors.as_slice() + } Ok(value) => { - let serialized = - crate::serialize(value, command.format, command.pretty)?; - println!("{serialized}"); + print_eval_result(value, command.format, command.pretty)?; &[] } }; @@ -124,6 +126,34 @@ fn evaluate_expression( ) } +/// Prints the output value of `typst eval`. +fn print_eval_result( + value: &Value, + format: EvalSerializationFormat, + pretty: bool, +) -> HintedStrResult<()> { + match format { + EvalSerializationFormat::Json => { + println!("{}", crate::serialize(&value, SerializationFormat::Json, pretty)?); + } + EvalSerializationFormat::Yaml => { + println!("{}", crate::serialize(&value, SerializationFormat::Yaml, pretty)?); + } + EvalSerializationFormat::Raw => { + let bytes = match value { + Value::Str(string) => string.as_bytes(), + Value::Bytes(bytes) => bytes, + _ => bail!( + "cannot print {} in raw format", value.ty(); + hint: "`--format=raw` only supports strings and bytes" + ), + }; + stdout().lock().write_all(bytes).expect("failed to write eval output"); + } + } + Ok(()) +} + /// Static [`FileId`] for an input expression. This allows giving accurate /// ranges to diagnostics when evaluating the expression. static EXPRESSION_ID: LazyLock = LazyLock::new(|| { diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index 9cab90489850..183c3b0c5c4c 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -32,11 +32,11 @@ pub fn query(command: &'static QueryCommand) -> HintedStrResult<()> { let Warned { output, mut warnings } = match command.target { Target::Paged => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), Target::Html => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), Target::Bundle => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), }; // Add deprecation warning. diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 867b96d4e883..1c29e459dc71 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -11,6 +11,7 @@ use typst_kit::watcher::Watcher; use crate::args::{Input, Output, WatchCommand}; use crate::compile::{CompileConfig, compile_once, print_diagnostics}; +use crate::terminal::TermOut; use crate::world::{SystemWorld, WorldCreationError}; use crate::{print_error, terminal}; @@ -94,10 +95,26 @@ pub enum Status { impl Status { /// Clear the terminal and render the status message. pub fn print(&self, config: &CompileConfig) -> io::Result<()> { - let timestamp = chrono::offset::Local::now().format("%H:%M:%S"); - let color = self.color(); - let mut out = terminal::out(); + + if config.fullscreen { + self.print_fullscreen(&mut out, config)?; + } else { + self.print_message(&mut out)?; + if !matches!(self, Status::Compiling) { + writeln!(out)?; + } + } + + out.flush() + } + + fn print_fullscreen( + &self, + out: &mut TermOut, + config: &CompileConfig, + ) -> io::Result<()> { + let color = self.color(); out.clear_screen()?; out.set_color(&color)?; @@ -122,10 +139,15 @@ impl Status { } writeln!(out)?; - writeln!(out, "[{timestamp}] {}", self.message())?; + self.print_message(out)?; writeln!(out)?; - out.flush() + Ok(()) + } + + fn print_message(&self, out: &mut TermOut) -> io::Result<()> { + let timestamp = chrono::offset::Local::now().format("%H:%M:%S"); + writeln!(out, "[{timestamp}] {}", self.message()) } fn message(&self) -> String { diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index e1a1c429372b..19d795e070e7 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -64,7 +64,16 @@ impl SystemWorld { let features = process_args.features.iter().copied().map(Into::into).collect(); - Library::builder().with_inputs(inputs).with_features(features).build() + Library::builder([ + typst_html::FORMAT, + typst_pdf::FORMAT, + typst_svg::FORMAT, + typst_render::FORMAT, + typst_bundle::FORMAT, + ]) + .with_inputs(inputs) + .with_features(features) + .build() }; let now = match world_args.creation_timestamp { diff --git a/crates/typst-cli/tests/smoke.rs b/crates/typst-cli/tests/smoke.rs index 53be8af8f0db..d6aa2889d7ef 100644 --- a/crates/typst-cli/tests/smoke.rs +++ b/crates/typst-cli/tests/smoke.rs @@ -41,10 +41,77 @@ fn test_compile_pdf_version() { .must_contain(format!("/Creator(Typst {version})").as_bytes()); } +#[test] +fn test_compile_pdf_tagged_and_pages() { + let project = tempfs(); + let hello = project.write("hello.typ", ""); + let output = exec() + .arg("compile") + .arg("--pdf-tagged") + .arg("--pages=1") + .arg(&hello) + .must_fail(); + output + .stderr + .must_contain("cannot enable PDF tags when exporting a page range"); +} + +#[test] +fn test_compile_pages_warning_untagged() { + let project = tempfs(); + let hello = project.write("hello.typ", ""); + let output = exec().arg("compile").arg("--pages=1").arg(&hello).must_succeed(); + output + .stderr + .must_contain("using `--pages` implies `--pdf-tagged=false`"); +} + +#[test] +fn test_compile_pdf_untagged_and_accessible_standard() { + let project = tempfs(); + let hello = project.write("hello.typ", ""); + let output = exec() + .arg("compile") + .arg("--pdf-tagged=false") + .arg("--pdf-standard=a-3a") + .arg(&hello) + .must_fail(); + output + .stderr + .must_contain("cannot disable PDF tags when exporting a PDF/A-3a document"); +} + +#[test] +fn test_compile_no_pdf_tags_deprecated() { + let project = tempfs(); + let hello = project.write("hello.typ", ""); + let output = exec().arg("compile").arg("--no-pdf-tags").arg(&hello).must_succeed(); + output.stderr.must_contain("`--no-pdf-tags` is deprecated"); +} + #[test] fn test_eval() { let output = exec().arg("eval").arg("1+2").must_succeed(); output.stdout.must_match_lines(["3"]); + + let output = exec() + .arg("eval") + .arg("--format=raw") + .arg("bytes((1,2,3,0xff))") + .must_succeed(); + assert_eq!(output.stdout.0, b"\x01\x02\x03\xff"); + + // Trailing newline. + let output = exec().arg("eval").arg("str(42)").must_succeed(); + assert_eq!(output.stdout.0, b"\"42\"\n"); + + // No trailing newline. + let output = exec().arg("eval").arg("--format=raw").arg("str(42)").must_succeed(); + assert_eq!(output.stdout.0, b"42"); + + // Unsupported type. + let output = exec().arg("eval").arg("--format=raw").arg("42").must_fail(); + output.stderr.must_contain("cannot print integer in raw format"); } #[test] diff --git a/crates/typst-cnd/Cargo.toml b/crates/typst-cnd/Cargo.toml index 3ec90bf16e6e..d604b7279680 100644 --- a/crates/typst-cnd/Cargo.toml +++ b/crates/typst-cnd/Cargo.toml @@ -19,6 +19,7 @@ typst-kit = { workspace = true, features = ["embedded-fonts", "scan-fonts", "sys typst-layout = { workspace = true } typst-library = { workspace = true } typst-macros = { workspace = true } +typst-pdf = { workspace = true } typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } diff --git a/crates/typst-cnd/src/document.rs b/crates/typst-cnd/src/document.rs index 5c3965028481..cfb889ac8db0 100644 --- a/crates/typst-cnd/src/document.rs +++ b/crates/typst-cnd/src/document.rs @@ -4,6 +4,7 @@ use ecow::eco_vec; use typst_layout::{PagedIntrospector, layout_document}; use typst_library::diag::{SourceResult, error}; use typst_library::engine::Engine; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{Content, Datetime, Output, Smart, StyleChain, Target}; use typst_library::introspection::Introspector; use typst_library::model::{Document, DocumentInfo}; @@ -25,6 +26,10 @@ pub struct CndDocument { bibliography: Vec, footnotes: Vec, introspector: Arc, + /// Format options set by the document (upstream #8496). CND exports none + /// of these formats, but `Document` requires them and they are carried + /// through unchanged from the paged layout this document is built on. + options: DocumentFormatOptions, } impl CndDocument { @@ -55,6 +60,10 @@ impl Document for CndDocument { fn info(&self) -> &DocumentInfo { &self.info } + + fn options(&self) -> &DocumentFormatOptions { + &self.options + } } impl Output for CndDocument { @@ -70,6 +79,7 @@ impl Output for CndDocument { let paged = layout_document(engine, content, styles)?; let introspector = paged.introspector().clone(); let info = paged.info().clone(); + let options = paged.options().clone(); // Containment is read off the laid-out frames: `Introspector::query` // returns a flat list, but the emit pipeline must know which located @@ -105,7 +115,14 @@ impl Output for CndDocument { let mut assigner = LocationAssigner::new(introspector.clone(), ctx.records); assigner.assign_all(&mut nodes); - Ok(Self { info, nodes, bibliography, footnotes, introspector }) + Ok(Self { + info, + nodes, + bibliography, + footnotes, + introspector, + options, + }) } fn introspector(&self) -> &dyn Introspector { diff --git a/crates/typst-cnd/src/emit/ancestry.rs b/crates/typst-cnd/src/emit/ancestry.rs index 3ccefd7e03ba..23d0b0af8277 100644 --- a/crates/typst-cnd/src/emit/ancestry.rs +++ b/crates/typst-cnd/src/emit/ancestry.rs @@ -24,10 +24,9 @@ use typst_library::introspection::{Location, Tag}; use typst_library::layout::{Frame, FrameItem}; use typst_library::math::EquationElem; use typst_library::model::{ - BibliographyElem, EnumElem, FigureElem, FootnoteEntry, HeadingElem, ListElem, - OutlineElem, ParElem, QuoteElem, TableElem, TermsElem, + ArtifactElem, BibliographyElem, EnumElem, FigureElem, FootnoteEntry, HeadingElem, + ListElem, OutlineElem, ParElem, QuoteElem, TableElem, TermsElem, }; -use typst_library::pdf::ArtifactElem; use typst_library::text::RawElem; use typst_syntax::{FileId, Span}; diff --git a/crates/typst-cnd/src/world.rs b/crates/typst-cnd/src/world.rs index 6fe0b45230cd..fae481425580 100644 --- a/crates/typst-cnd/src/world.rs +++ b/crates/typst-cnd/src/world.rs @@ -33,7 +33,14 @@ fn build_library() -> Library { // located, so `Introspector::query` — the only source the CND emit // pipeline reads — cannot see it and the content is silently dropped. let features = Features::from_iter([Feature::CndSemantics]); - let mut library = Library::builder().with_features(features).build(); + // Upstream #8496 made every export format opt-in: a format's bindings + // (`pdf.embed`, `pdf.header-cell`, …) now exist only if the format is + // registered here. Before it, `pdf` was defined unconditionally, so + // omitting it would reject documents the CND exporter used to accept. + // The other formats were never reachable from this world, so they stay + // unregistered — CND is this world's only output. + let mut library = + Library::builder([typst_pdf::FORMAT]).with_features(features).build(); // DEPRECATED — see crate::cnd's module doc. Removal tracked for the next release. library.global.scope_mut().define("cnd", crate::cnd::module()); library diff --git a/crates/typst-cnd/tests/document_suite.rs b/crates/typst-cnd/tests/document_suite.rs index a5563a272a50..336a035314fc 100644 --- a/crates/typst-cnd/tests/document_suite.rs +++ b/crates/typst-cnd/tests/document_suite.rs @@ -1144,22 +1144,28 @@ fn ref_text_spans_are_codepoint_offsets() { assert!(!spans.is_empty(), "cross-reference markers should carry text spans"); // Every span slices to the rendered reference text, and the `\u{a0}` - // inside "Chapitre 11" / "Tableau 1" is the codepoint-vs-byte canary: a + // inside "Section 11" / "Tableau 1" is the codepoint-vs-byte canary: a // byte offset would land mid-character here. - let mut saw_chapitre_11 = false; + // + // The supplement is whatever `translations/fr.txt` gives for `heading` + // (upstream #8728 changed it from "Chapitre" to "Section"). If this + // assertion fails on a word, check that file before suspecting the + // offsets — the invariant under test is the offset arithmetic, not the + // wording. + let mut saw_numbered_heading_ref = false; for (text, label, span) in &spans { let slice = codepoint_slice(text, span); assert!( slice.contains('\u{a0}'), "ref {label} span {span:?} slices to a numbered reference: {slice:?}" ); - if label == "sec-detail" && slice == "Chapitre\u{a0}11" { - saw_chapitre_11 = true; + if label == "sec-detail" && slice == "Section\u{a0}11" { + saw_numbered_heading_ref = true; } } assert!( - saw_chapitre_11, - "expected a ref rendering \"Chapitre\\u{{a0}}11\" (11 code points, 12 bytes): {spans:?}" + saw_numbered_heading_ref, + "expected a ref rendering \"Section\\u{{a0}}11\" (10 code points, 11 bytes): {spans:?}" ); assert_refs_resolve(&cnd.nodes); diff --git a/crates/typst-eval/src/access.rs b/crates/typst-eval/src/access.rs index 59476e1e19d6..1c699e0abe5f 100644 --- a/crates/typst-eval/src/access.rs +++ b/crates/typst-eval/src/access.rs @@ -1,6 +1,6 @@ use ecow::eco_format; use typst_library::diag::{At, Hint, SourceResult, Trace, Tracepoint, bail}; -use typst_library::foundations::{Dict, Value}; +use typst_library::foundations::{Dict, Value, WorldBindingExt}; use typst_syntax::ast::{self, AstNode}; use crate::{Eval, Vm, call_method_access, is_accessor_method}; @@ -29,15 +29,15 @@ impl Access for ast::Expr<'_> { impl Access for ast::Ident<'_> { fn access<'a>(self, vm: &'a mut Vm) -> SourceResult<&'a mut Value> { let span = self.span(); + let ident = self.as_str(); if vm.inspected == Some(span) - && let Ok(binding) = vm.scopes.get(&self) + && let Ok(value) = + vm.scopes.get(ident, vm.engine.world.silent_binding_guard()) { - vm.trace(binding.read().clone()); + vm.trace(value.clone()); } - vm.scopes - .get_mut(&self) - .and_then(|b| b.write().map_err(Into::into)) - .at(span) + + vm.scopes.get_mut(&self, vm.engine.binding_guard(span)).at(span) } } diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index e0832b8ac9d8..0e1a22c80536 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -6,8 +6,8 @@ use typst_library::diag::{ }; use typst_library::engine::{Engine, Sink, Traced}; use typst_library::foundations::{ - Arg, Args, Binding, Capturer, Closure, ClosureNode, Content, Context, Func, - NativeElement, Scope, Scopes, SequenceElem, SymbolElem, Value, + Arg, Args, Binding, BindingAccess, Capturer, Closure, ClosureNode, Content, Context, + Func, NativeElement, Scope, Scopes, SequenceElem, SymbolElem, Value, }; use typst_library::introspection::Introspector; use typst_library::math::LrElem; @@ -245,24 +245,33 @@ fn eval_field_callee( target: Value, in_math: bool, ) -> SourceResult { - let sink = (&mut vm.engine, field_span); + let guard = vm.engine.binding_guard(field_span); let mut is_method_call = false; let callee_value = if let Some(method) = target.ty().scope().get(field) { is_method_call = true; - method.read_checked(sink).clone() + let ty = target.ty().short_name(); + method + .read(guard) + .or_cannot(format_args!("call method `{field}` on {ty}")) + .at(field_span)? + .clone() } else if let Value::Content(content) = &target && let Some(method) = content.elem().scope().get(field) { is_method_call = true; - method.read_checked(sink).clone() + method + .read(guard) + .or_cannot(format_args!("call method `{field}` on content")) + .at(field_span)? + .clone() } else if matches!(target, Value::Symbol(_) | Value::Type(_) | Value::Module(_)) { // These types are allowed to use field call syntax on non-methods. - target.field(field, sink).at(field_span)? + target.field(field, guard).at(field_span)? } else if let Value::Func(func) = &target { // Functions can also use field call syntax on non-methods, but not for // settable fields accessed from context. - match target.field(field, sink).at(field_span) { + match target.field(field, guard).at(field_span) { Ok(callee_value) => callee_value, Err(err) => { if let Some(element) = func.to_element() @@ -284,7 +293,7 @@ fn eval_field_callee( } } else { // Otherwise we are not allowed to call the field and produce an error. - match target.field(field, sink) { + match target.field(field, guard) { // The field does exist. Ok(callee_value) => { bail!(disallowed_field_call_error( @@ -773,9 +782,11 @@ impl<'a> CapturesVisitor<'a> { // Identifiers that shouldn't count as captures because they // actually bind a new name are handled below (individually through // the expressions that contain them). - Some(ast::Expr::Ident(ident)) => self.capture(ident.get(), Scopes::get), + Some(ast::Expr::Ident(ident)) => { + self.capture(ident.get(), Scopes::get_binding); + } Some(ast::Expr::MathIdent(ident)) => { - self.capture(ident.get(), Scopes::get_in_math); + self.capture(ident.get(), Scopes::get_binding_in_math); } // Code and content blocks create a scope. @@ -899,7 +910,13 @@ impl<'a> CapturesVisitor<'a> { ident: &EcoString, getter: impl FnOnce(&'a Scopes<'a>, &str) -> HintedStrResult<&'a Binding>, ) { - if self.internal.get(ident).is_ok() { + if self.internal.get_binding(ident).is_ok() { + return; + } + + // If the variable has already been captured, there is no need to look + // it up again, since the external scopes don't change. + if self.captures.get(ident).is_some() { return; } diff --git a/crates/typst-eval/src/code.rs b/crates/typst-eval/src/code.rs index ca63b7a59297..9572f01c9f30 100644 --- a/crates/typst-eval/src/code.rs +++ b/crates/typst-eval/src/code.rs @@ -160,12 +160,7 @@ impl Eval for ast::Ident<'_> { fn eval(self, vm: &mut Vm) -> SourceResult { let span = self.span(); - Ok(vm - .scopes - .get(&self) - .at(span)? - .read_checked((&mut vm.engine, span)) - .clone()) + vm.scopes.get(&self, vm.engine.binding_guard(span)).at(span).cloned() } } @@ -366,7 +361,10 @@ pub(crate) fn access_field( field: &str, field_span: Span, ) -> SourceResult { - let err = match target.field(field, (&mut vm.engine, field_span)).at(field_span) { + let err = match target + .field(field, vm.engine.binding_guard(field_span)) + .at(field_span) + { Ok(value) => return Ok(value), Err(err) => err, }; diff --git a/crates/typst-eval/src/import.rs b/crates/typst-eval/src/import.rs index da145af0d096..131deef3dac7 100644 --- a/crates/typst-eval/src/import.rs +++ b/crates/typst-eval/src/import.rs @@ -5,7 +5,9 @@ use typst_library::diag::{ At, FileError, SourceResult, Trace, Tracepoint, bail, error, warning, }; use typst_library::engine::Engine; -use typst_library::foundations::{Binding, Content, Module, PathOrStr, Reflect, Value}; +use typst_library::foundations::{ + Binding, BindingAccess, Content, Module, PathOrStr, Reflect, Value, WorldBindingExt, +}; use typst_syntax::ast::{self, AstNode, BareImportError}; use typst_syntax::package::{PackageManifest, PackageSpec}; use typst_syntax::{FileId, RootedPath, Span, VirtualPath, VirtualRoot}; @@ -107,7 +109,11 @@ impl Eval for ast::ModuleImport<'_> { } Some(ast::Imports::Wildcard) => { for (var, binding) in scope.iter() { - vm.scopes.top.bind(var.clone(), binding.clone()); + // Filter out values that are in feature gated bindings and + // ignore any deprecation warnings that are emitted. + if binding.read(vm.engine.world.silent_binding_guard()).is_ok() { + vm.scopes.top.bind(var.clone(), binding.clone()); + } } } Some(ast::Imports::Items(items)) => { @@ -117,15 +123,20 @@ impl Eval for ast::ModuleImport<'_> { let mut scope = scope; while let Some(component) = &path.next() { - let Some(binding) = scope.get(component) else { + let field = component.as_str(); + let Some(binding) = scope.get(field) else { errors.push(error!(component.span(), "unresolved import")); break; }; + let value = binding + .read(vm.engine.binding_guard(component.span())) + .or_cannot(format_args!("import `{field}`")) + .at(component.span())?; + if path.peek().is_some() { // Nested import, as this is not the last component. // This must be a submodule. - let value = binding.read(); let Some(submodule) = value.scope() else { let error = if matches!(value, Value::Func(function) if function.scope().is_none()) { diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index 0eb09e33728b..8b734caacf14 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -98,7 +98,11 @@ impl Eval for ast::Space<'_> { type Output = Content; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(SpaceElem::shared().clone()) + if self.had_newline() { + Ok(SpaceElem::shared_with_newline().clone()) + } else { + Ok(SpaceElem::shared().clone()) + } } } diff --git a/crates/typst-eval/src/math.rs b/crates/typst-eval/src/math.rs index 64235078cfb6..3eb62a7b6172 100644 --- a/crates/typst-eval/src/math.rs +++ b/crates/typst-eval/src/math.rs @@ -49,9 +49,8 @@ impl Eval for ast::MathIdent<'_> { let span = self.span(); Ok(vm .scopes - .get_in_math(&self) + .get_in_math(&self, vm.engine.binding_guard(span)) .at(span)? - .read_checked((&mut vm.engine, span)) .clone()) } } diff --git a/crates/typst-eval/src/ops.rs b/crates/typst-eval/src/ops.rs index ba3ceb74c3e5..4a78aa1c2993 100644 --- a/crates/typst-eval/src/ops.rs +++ b/crates/typst-eval/src/ops.rs @@ -1,6 +1,7 @@ use ecow::eco_format; use typst_library::diag::{At, HintedStrResult, SourceResult, bail, error}; -use typst_library::foundations::{IntoValue, Value, ops}; +use typst_library::foundations::{IntoValue, NormalBindingGuard, Value, ops}; +use typst_syntax::Span; use typst_syntax::ast::{self, AstNode}; use crate::{Access, Eval, Vm, access_dict}; @@ -39,8 +40,12 @@ impl Eval for ast::Binary<'_> { ast::BinOp::Leq => apply_binary(self, vm, ops::leq), ast::BinOp::Gt => apply_binary(self, vm, ops::gt), ast::BinOp::Geq => apply_binary(self, vm, ops::geq), - ast::BinOp::In => apply_binary(self, vm, ops::in_), - ast::BinOp::NotIn => apply_binary(self, vm, ops::not_in), + ast::BinOp::In => { + apply_binary_with(self, vm, |guard, a, b| ops::in_(guard, a, b)) + } + ast::BinOp::NotIn => { + apply_binary_with(self, vm, |guard, a, b| ops::not_in(guard, a, b)) + } ast::BinOp::Assign => apply_assignment(self, vm, |_, b| Ok(b)), ast::BinOp::AddAssign => apply_assignment(self, vm, ops::add), ast::BinOp::SubAssign => apply_assignment(self, vm, ops::sub), @@ -69,6 +74,19 @@ fn apply_binary( op(lhs, rhs).at(binary.span()) } +/// Apply a basic binary operation with a [`BindingGuard`]. +/// +/// [`BindingGuard`]: typst_library::foundations::BindingGuard +fn apply_binary_with( + binary: ast::Binary, + vm: &mut Vm, + op: fn(NormalBindingGuard, Value, Value) -> HintedStrResult, +) -> SourceResult { + let lhs = binary.lhs().eval(vm)?; + let rhs = binary.rhs().eval(vm)?; + op(vm.engine.binding_guard(Span::detached()), lhs, rhs).at(binary.span()) +} + /// Apply an assignment operation. fn apply_assignment( binary: ast::Binary, diff --git a/crates/typst-eval/src/vm.rs b/crates/typst-eval/src/vm.rs index a202e8141a7d..cefc28da1476 100644 --- a/crates/typst-eval/src/vm.rs +++ b/crates/typst-eval/src/vm.rs @@ -3,7 +3,9 @@ use ecow::eco_format; use typst_library::World; use typst_library::diag::{HintedString, warning}; use typst_library::engine::Engine; -use typst_library::foundations::{Binding, Context, IntoValue, Scopes, Value}; +use typst_library::foundations::{ + Binding, Context, IntoValue, Scopes, Value, WorldBindingExt, +}; use typst_syntax::Span; use typst_syntax::ast::{self, AstNode}; @@ -56,7 +58,9 @@ impl<'a> Vm<'a> { /// This will insert the value into the top-most scope and make it available /// for dynamic tracing, assisting IDE functionality. pub fn bind(&mut self, var: ast::Ident, binding: Binding) { - self.trace_at(var.span(), binding.read()); + if let Ok(value) = binding.read(self.engine.world.silent_binding_guard()) { + self.trace_at(var.span(), value); + } // This will become an error in the parser if `is` becomes a keyword. if var.get() == "is" { diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs index 15eebdcb3181..7c15c4db18c8 100644 --- a/crates/typst-html/src/document.rs +++ b/crates/typst-html/src/document.rs @@ -2,6 +2,7 @@ use comemo::{Track, Tracked, TrackedMut}; use ecow::{EcoVec, eco_vec}; use typst_library::diag::{SourceResult, bail, error}; use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{Content, NativeElement, StyleChain, Styles}; use typst_library::introspection::{ Introspector, Locator, LocatorLink, QueryIntrospection, @@ -10,7 +11,7 @@ use typst_library::math::EquationElem; use typst_library::model::{DocumentInfo, FootnoteContainer, FootnoteMarker}; use typst_library::routines::{Arenas, RealizationKind}; use typst_library::{Library, World}; -use typst_syntax::Span; +use typst_syntax::{Span, Spanned}; use typst_utils::{LazyHash, Protected}; use crate::convert::{ConversionLevel, Whitespace}; @@ -159,9 +160,11 @@ fn html_document_common( let mut info = DocumentInfo::default(); info.populate(styles); info.populate_locale(styles); + let mut options = DocumentFormatOptions::new(&library.formats); + options.populate(Spanned::detached(styles)); let children = (engine.library.routines.realize)( - RealizationKind::Document { info: &mut info }, + RealizationKind::Document { info: &mut info, options: &mut options }, &mut engine, &mut locator, &arenas, @@ -214,7 +217,7 @@ fn html_document_common( ); } - Ok(HtmlDocument::new(output, info)) + Ok(HtmlDocument::new(output, info, options)) } /// The introspectible output of HTML compilation. @@ -340,11 +343,11 @@ fn head_element(info: &DocumentInfo) -> HtmlElement { ); } - if !info.author.is_empty() { + for author in &info.author { children.push( HtmlElement::new(tag::meta) - .with_attr(attr::name, "authors") - .with_attr(attr::content, info.author.join(", ")) + .with_attr(attr::name, "author") + .with_attr(attr::content, author) .into(), ); } diff --git a/crates/typst-html/src/dom.rs b/crates/typst-html/src/dom.rs index a6ed85bd8df1..490b158e7bcd 100644 --- a/crates/typst-html/src/dom.rs +++ b/crates/typst-html/src/dom.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use ecow::{EcoString, EcoVec}; use typst_library::diag::{HintedStrResult, SourceResult, StrResult, bail}; use typst_library::engine::Engine; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{ Content, Dict, Fold, Output, Repr, Str, StyleChain, Target, cast, }; @@ -26,16 +27,26 @@ use crate::{HtmlIntrospector, charsets, css}; pub struct HtmlDocument { output: HtmlOutput, info: DocumentInfo, + options: DocumentFormatOptions, introspector: Arc, } impl HtmlDocument { - /// Creates a new paged document from its parts. + /// Creates a new HTML document from its parts. /// /// Internally builds the introspector. - pub fn new(output: HtmlOutput, info: DocumentInfo) -> Self { + pub fn new( + output: HtmlOutput, + info: DocumentInfo, + options: DocumentFormatOptions, + ) -> Self { let introspector = HtmlIntrospector::new(output.nodes()); - Self { output, info, introspector: Arc::new(introspector) } + Self { + output, + info, + options, + introspector: Arc::new(introspector), + } } /// The document's root HTML element. @@ -76,6 +87,10 @@ impl Document for HtmlDocument { fn info(&self) -> &DocumentInfo { &self.info } + + fn options(&self) -> &DocumentFormatOptions { + &self.options + } } impl Output for HtmlDocument { diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index ee1bcbd5b3b6..6699f188e5d5 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -3,26 +3,35 @@ use std::fmt::Write; use comemo::{Track, Tracked}; use ecow::{EcoString, eco_format}; use typst_library::diag::{At, SourceResult, StrResult, bail}; +use typst_library::format::{Complete, Fields, Partial}; use typst_library::foundations::Repr; -use typst_library::model::LateLinkResolver; +use typst_library::model::{Document, LateLinkResolver}; use typst_syntax::Span; use crate::{ - HtmlDocument, HtmlElement, HtmlFrame, HtmlNode, HtmlTag, attr, charsets, property, - tag, + HtmlDocument, HtmlElement, HtmlFormat, HtmlFormatOptions, HtmlFrame, HtmlNode, + HtmlTag, attr, charsets, property, tag, }; /// Settings for HTML export. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] -pub struct HtmlOptions { - /// Whether to format the HTML in a human-readable way. - pub pretty: bool, +pub struct HtmlOptions { + /// Format options that override the defaults set by the document. + pub format: HtmlFormatOptions, +} + +impl HtmlOptions { + /// Resolves the final options given those set by the document. + pub fn resolve(&self, doc: &HtmlFormatOptions) -> HtmlOptions { + HtmlOptions { format: self.format.resolve(doc) } + } } /// Encodes an HTML document into a string. pub fn html(document: &HtmlDocument, options: &HtmlOptions) -> SourceResult { + let options = options.resolve(document.options().get::()); let link_resolver = LateLinkResolver::new(None, document.introspector().as_ref()); - let w = Writer::new(link_resolver.track(), options.pretty); + let w = Writer::new(link_resolver.track(), options.format.pretty.v); html_impl(w, document.root()) } @@ -32,10 +41,10 @@ pub fn html(document: &HtmlDocument, options: &HtmlOptions) -> SourceResult, link_resolver: Tracked, ) -> SourceResult { - let w = Writer::new(link_resolver, options.pretty); + let w = Writer::new(link_resolver, options.format.pretty.v); html_impl(w, root) } diff --git a/crates/typst-html/src/format.rs b/crates/typst-html/src/format.rs new file mode 100644 index 000000000000..3f78c9f32b47 --- /dev/null +++ b/crates/typst-html/src/format.rs @@ -0,0 +1,282 @@ +use ecow::EcoString; +use typst_library::Feature; +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::Engine; +use typst_library::format::{Complete, Fields, Format, FormatElement, Partial, Populate}; +use typst_library::foundations::{Args, Construct, Content, Scope, StyleChain}; +use typst_library::introspection::Location; +use typst_macros::{elem, scope}; +use typst_syntax::Spanned; + +use crate::{HtmlAttr, HtmlAttrs, HtmlTag, css}; + +/// The format element for registering the HTML format. +pub const FORMAT: Format = Format::new::().with_feature(Feature::Html); + +/// Typst's HTML export format (experimental). +/// +/// #info[ +/// Typst's HTML export is currently under active development. The feature is +/// still very incomplete and only available for experimentation behind a +/// feature flag. Do not use this feature for production use cases. In the +/// CLI, you can experiment with HTML export by passing `--features html` or +/// setting the `TYPST_FEATURES` environment variable to `html`. In the web +/// app, you can likewise enable it as an experimental feature. Visit the +/// #link("https://github.com/typst/typst/issues/5512")[tracking issue] to +/// follow progress on HTML export and learn more about planned features. +/// ] +/// +/// HTML files describe a document structurally. The aim of Typst's HTML export +/// is to capture the structure of an input document and produce semantically +/// rich HTML that retains this structure. The resulting HTML should be +/// accessible, human-readable, and editable by hand and downstream tools. +/// +/// PDF, PNG, and SVG export, in contrast, all produce _visual_ representations +/// of a fully-laid out document. This divergence in the formats' intents means +/// that Typst cannot simply produce perfect HTML for your existing Typst +/// documents. It cannot always know what the best semantic HTML representation +/// of your content is. +/// +/// Instead, it gives _you_ full control: You can check the current export +/// format through the @target function and when it is set to HTML, generate +/// @html.elem[raw HTML elements]. The primary intended use of these elements is +/// in templates and show rules. This way, the document's contents can be fully +/// agnostic to the export target and content can be shared between PDF and HTML +/// export. +/// +/// In the `html` export format, Typst will output a single HTML file. You can +/// also use Typst to create a website comprising multiple HTML documents and +/// additional assets. Typst calls such a collection a _bundle_ and supports +/// this use case with the dedicated @format.bundle[`bundle` target]. Refer to +/// its documentation to learn more. Note that both in the `html` and the +/// `bundle` target, Typst will currently always emit a standalone HTML file. +/// Support for emitting fragments that can be integrated into other HTML +/// documents is planned for the future. +/// +/// Typst currently does not output CSS style sheets, instead focussing on +/// emitting semantic markup. You can of course write your own CSS styles and +/// still benefit from sharing your _content_ between PDF and HTML. For the +/// future, we plan to give you the option of automatically emitting CSS, taking +/// more of your existing set rules into account. +/// +/// = Exporting as HTML +/// == Command Line +/// Pass `--format html` to the `compile` or `watch` subcommand or provide an +/// output file name that ends with `.html`. Note that you must also pass +/// `--features html` or set `TYPST_FEATURES=html` to enable this experimental +/// export target. +/// +/// When using `typst watch`, Typst will launch a live-reloading HTTP server. +/// You can configure it as follows: +/// +/// - Pass `--port` to change the port. (Defaults to the first free port in the +/// range 3000-3005.) +/// - Pass `--no-reload` to disable injection of a live reload script. (The HTML +/// that is written to disk isn't affected either way.) +/// - Pass `--no-serve` to disable the server altogether. +/// +/// == Web App +/// HTML export can be enabled as an experimental feature in the web app. +/// #insertion("html-web-app") +/// +/// = HTML-specific functionality +/// Typst exposes HTML-specific functionality in the global `html` element. See +/// below for the definitions and options it contains. +/// +/// #category-settings(groups: ( +/// ( +/// name: "typed", +/// title: "Typed HTML", +/// items: dictionary(stdx.describe(html).scope) +/// .filter(val => "typed-html" in stdx.describe(val).keywords), +/// description: "Documentation for the typed HTML API.", +/// docs: [ +/// A typed layer over raw HTML elements. +/// +/// The `html` module provides a typed layer over the raw @html.elem +/// function that allows you to conveniently create HTML elements. HTML +/// attributes are exposed as function parameters that accept Typst +/// types and automatically take care of converting those into the +/// appropriate HTML. +/// +/// Some parameters are common to all typed HTML functions. These are +/// listed at the bottom in the +/// @html:typed:global-attributes[Global Attributes] section instead of +/// explicitly on each element for readability. +/// +/// = Example +/// ```typ +/// #html.video( +/// controls: true, +/// width: 1280, +/// height: 720, +/// src: "sunrise.mp4", +/// )[ +/// Your browser does not support the video tag. +/// ] +/// ``` +/// ], +/// epilogue: [ +/// = Global Attributes +/// These parameters are common to all typed HTML functions. They are +/// listed here once instead of explicitly on each element for +/// readability. +/// ], +/// ), +/// )) +#[elem(scope, name = "html", title = "HTML", since = "0.13.0", Construct)] +pub struct HtmlFormat { + /// Whether to pretty-print the produced HTML document. + /// + /// This formats the output in a more human-readable, but less + /// space-efficient way. + #[default(false)] + pub pretty: bool, +} + +impl Construct for HtmlFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +impl FormatElement for HtmlFormat { + type Options = HtmlFormatOptions; +} + +#[scope(category = Html)] +impl HtmlFormat { + #[elem] + type HtmlElem; + + #[elem] + type FrameElem; + + #[defs] + fn definitions(scope: &mut Scope) { + crate::typed::define(scope); + } +} + +/// Document settings for HTML export. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct HtmlFormatOptions { + pub pretty: F::Value, +} + +impl Populate for HtmlFormatOptions { + fn populate(&mut self, styles: Spanned) { + // VOLATILE: This must be updated when adding more fields. + self.pretty.populate(styles); + } +} + +impl HtmlFormatOptions { + /// Resolves the [`Partial`] options to [`Complete`] ones, given defaults. + pub fn resolve(&self, default: &HtmlFormatOptions) -> HtmlFormatOptions { + HtmlFormatOptions { + pretty: Partial::resolve(self.pretty, default.pretty), + } + } +} + +/// An HTML element that can contain Typst content. +/// +/// Typst's HTML export automatically generates the appropriate tags for most +/// elements. However, sometimes, it is desirable to retain more control. For +/// example, when using Typst to generate your blog, you could use this function +/// to wrap each article in an `
` tag. +/// +/// Typst is aware of what is valid HTML. A tag and its attributes must form +/// syntactically valid HTML. Some tags, like `meta` do not accept content. +/// Hence, you must not provide a body for them. We may add more checks in the +/// future, so be sure that you are generating valid HTML when using this +/// function. +/// +/// Normally, Typst will generate `html`, `head`, and `body` tags for you. If +/// you instead create them with this function, Typst will omit its own tags. +/// +/// ```typ +/// #html.elem("div", attrs: (style: "background: aqua"))[ +/// A div with _Typst content_ inside! +/// ] +/// ``` +#[elem(name = "elem", since = "0.13.0")] +pub struct HtmlElem { + /// The element's tag. + #[required] + pub tag: HtmlTag, + + /// The element's HTML attributes. + #[fold] + pub attrs: HtmlAttrs, + + /// The element's CSS properties. Currently only used for generated styles. + #[internal] + #[parse(Some(css::Properties::default()))] + pub css: css::Properties, + + /// The contents of the HTML element. + /// + /// The body can be arbitrary Typst content. + #[positional] + pub body: Option, + + /// The element's logical parent, if any. + #[internal] + #[synthesized] + pub parent: Location, + + /// A role that should be applied to the top-level styled HTML element, but + /// not its descendants. If we ever get set rules that apply to a specific + /// element instead of a subtree, they could supplant this. If we need the + /// same mechanism for things like `class`, this could potentially also be + /// extended to arbitrary attributes. It's minimal for now. + /// + /// This is ignored for `

` elements as it otherwise tends to + /// unintentionally attach to paragraphs resulting from grouping of a single + /// element instead of attaching to that element. This is a bit of a hack, + /// but good enough for now as the `role` property is purely internal and we + /// control what it is used for. + #[internal] + #[ghost] + pub role: Option, +} + +impl HtmlElem { + /// Add an attribute to the element. + pub fn with_attr(mut self, attr: HtmlAttr, value: impl Into) -> Self { + self.attrs + .as_option_mut() + .get_or_insert_with(Default::default) + .push(attr, value); + self + } + + /// Adds the attribute to the element if value is not `None`. + pub fn with_optional_attr( + self, + attr: HtmlAttr, + value: Option>, + ) -> Self { + if let Some(value) = value { self.with_attr(attr, value) } else { self } + } +} + +/// An element that lays out its content as an inline SVG. +/// +/// Sometimes, converting Typst content to HTML is not desirable. This can be +/// the case for plots and other content that relies on positioning and styling +/// to convey its message. +/// +/// This function allows you to use the Typst layout engine that would also be +/// used for PDF, SVG, and PNG export to render a part of your document exactly +/// how it would appear when exported in one of these formats. It embeds the +/// content as an inline SVG. +#[elem(since = "0.13.0")] +pub struct FrameElem { + /// The content that shall be laid out. + #[positional] + #[required] + pub body: Content, +} diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index 11fa4cfa2eb7..4ff6b2c6b5a6 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -10,6 +10,7 @@ mod css; mod document; mod dom; mod encode; +mod format; mod fragment; mod introspect; mod link; @@ -20,123 +21,7 @@ mod typed; pub use self::document::{html_document, html_document_for_bundle}; pub use self::dom::*; pub use self::encode::{HtmlOptions, html, html_in_bundle}; +pub use self::format::{FORMAT, FrameElem, HtmlElem, HtmlFormat, HtmlFormatOptions}; pub use self::introspect::HtmlIntrospector; pub use self::link::create_link_anchors; pub use self::rules::{html_mathml_body, html_span_filled, register}; - -use ecow::EcoString; -use typst_library::Category; -use typst_library::foundations::{Content, Module, Scope}; -use typst_library::introspection::Location; -use typst_macros::elem; - -/// Creates the module with all HTML definitions. -pub fn module() -> Module { - let mut html = Scope::deduplicating(); - html.start_category(Category::Html); - html.define_elem::(); - html.define_elem::(); - crate::typed::define(&mut html); - Module::new("html", html) -} - -/// An HTML element that can contain Typst content. -/// -/// Typst's HTML export automatically generates the appropriate tags for most -/// elements. However, sometimes, it is desirable to retain more control. For -/// example, when using Typst to generate your blog, you could use this function -/// to wrap each article in an `

` tag. -/// -/// Typst is aware of what is valid HTML. A tag and its attributes must form -/// syntactically valid HTML. Some tags, like `meta` do not accept content. -/// Hence, you must not provide a body for them. We may add more checks in the -/// future, so be sure that you are generating valid HTML when using this -/// function. -/// -/// Normally, Typst will generate `html`, `head`, and `body` tags for you. If -/// you instead create them with this function, Typst will omit its own tags. -/// -/// ```typ -/// #html.elem("div", attrs: (style: "background: aqua"))[ -/// A div with _Typst content_ inside! -/// ] -/// ``` -#[elem(name = "elem")] -pub struct HtmlElem { - /// The element's tag. - #[required] - pub tag: HtmlTag, - - /// The element's HTML attributes. - #[fold] - pub attrs: HtmlAttrs, - - /// The element's CSS properties. Currently only used for generated styles. - #[internal] - #[parse(Some(css::Properties::default()))] - pub css: css::Properties, - - /// The contents of the HTML element. - /// - /// The body can be arbitrary Typst content. - #[positional] - pub body: Option, - - /// The element's logical parent, if any. - #[internal] - #[synthesized] - pub parent: Location, - - /// A role that should be applied to the top-level styled HTML element, but - /// not its descendants. If we ever get set rules that apply to a specific - /// element instead of a subtree, they could supplant this. If we need the - /// same mechanism for things like `class`, this could potentially also be - /// extended to arbitrary attributes. It's minimal for now. - /// - /// This is ignored for `

` elements as it otherwise tends to - /// unintentionally attach to paragraphs resulting from grouping of a single - /// element instead of attaching to that element. This is a bit of a hack, - /// but good enough for now as the `role` property is purely internal and we - /// control what it is used for. - #[internal] - #[ghost] - pub role: Option, -} - -impl HtmlElem { - /// Add an attribute to the element. - pub fn with_attr(mut self, attr: HtmlAttr, value: impl Into) -> Self { - self.attrs - .as_option_mut() - .get_or_insert_with(Default::default) - .push(attr, value); - self - } - - /// Adds the attribute to the element if value is not `None`. - pub fn with_optional_attr( - self, - attr: HtmlAttr, - value: Option>, - ) -> Self { - if let Some(value) = value { self.with_attr(attr, value) } else { self } - } -} - -/// An element that lays out its content as an inline SVG. -/// -/// Sometimes, converting Typst content to HTML is not desirable. This can be -/// the case for plots and other content that relies on positioning and styling -/// to convey its message. -/// -/// This function allows you to use the Typst layout engine that would also be -/// used for PDF, SVG, and PNG export to render a part of your document exactly -/// how it would appear when exported in one of these formats. It embeds the -/// content as an inline SVG. -#[elem] -pub struct FrameElem { - /// The content that shall be laid out. - #[positional] - #[required] - pub body: Content, -} diff --git a/crates/typst-html/src/mathml.rs b/crates/typst-html/src/mathml.rs index 366fa1e27663..0a7222d7fbbc 100644 --- a/crates/typst-html/src/mathml.rs +++ b/crates/typst-html/src/mathml.rs @@ -100,31 +100,31 @@ pub(crate) static EQUATION_CSS_STYLES: LazyLock = LazyLock::new(|| { eco_format!( "\ /* Alignment */ -mtable.{RIGHT_ALIGN_CLASS} mtd, -mtable mtd.{RIGHT_ALIGN_CLASS}, -mtable.{LEFT_ALIGN_CLASS} mtd.{RIGHT_ALIGN_CLASS}, -mtable.{ALIGNED_CLASS} mtd:nth-child(odd) {{ +mtable.{RIGHT_ALIGN_CLASS} > mtr > mtd, +mtable > mtr > mtd.{RIGHT_ALIGN_CLASS}, +mtable.{LEFT_ALIGN_CLASS} > mtr > mtd.{RIGHT_ALIGN_CLASS}, +mtable.{ALIGNED_CLASS} > mtr > mtd:nth-child(odd) {{ justify-items: end; text-align: right; }} -mtable.{CASES_CLASS} mtd, -mtable.{LEFT_ALIGN_CLASS} mtd, -mtable mtd.{LEFT_ALIGN_CLASS}, -mtable.{ALIGNED_CLASS} mtd:nth-child(even), -math:is(:not([display])) > mtable.{MULTILINE_EQUATION_CLASS} mtd {{ +mtable.{CASES_CLASS} > mtr > mtd, +mtable.{LEFT_ALIGN_CLASS} > mtr > mtd, +mtable > mtr > mtd.{LEFT_ALIGN_CLASS}, +mtable.{ALIGNED_CLASS} > mtr > mtd:nth-child(even), +math:is(:not([display])) > mtable.{MULTILINE_EQUATION_CLASS} > mtr > mtd {{ justify-items: start; text-align: left; }} -mtable.{CASES_CLASS} mtd, -mtable.{ALIGNED_CLASS} mtd, -mtable mtd.{FLUSHED_CLASS}, -mtable mtd.{LEFT_FLUSH_CLASS} {{ +mtable.{CASES_CLASS} > mtr > mtd, +mtable.{ALIGNED_CLASS} > mtr > mtd, +mtable > mtr > mtd.{FLUSHED_CLASS}, +mtable > mtr > mtd.{LEFT_FLUSH_CLASS} {{ padding-left: 0; }} -mtable.{CASES_CLASS} mtd, -mtable.{ALIGNED_CLASS} mtd, -mtable mtd.{FLUSHED_CLASS}, -mtable mtd.{RIGHT_FLUSH_CLASS} {{ +mtable.{CASES_CLASS} > mtr > mtd, +mtable.{ALIGNED_CLASS} > mtr > mtd, +mtable > mtr > mtd.{FLUSHED_CLASS}, +mtable > mtr > mtd.{RIGHT_FLUSH_CLASS} {{ padding-right: 0; }} @@ -139,13 +139,13 @@ mtd {{ }} /* Equations */ -mtable.{MULTILINE_EQUATION_CLASS} mtd {{ +mtable.{MULTILINE_EQUATION_CLASS} > mtr > mtd {{ math-depth: inherit; math-style: inherit; math-shift: inherit; padding: 0; }} -math > mtable.{MULTILINE_EQUATION_CLASS} mtr:not(:last-child) mtd {{ +math > mtable.{MULTILINE_EQUATION_CLASS} > mtr:not(:last-child) > mtd {{ padding-bottom: {}; }} diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs index 9706436371c5..8451b6684f5d 100644 --- a/crates/typst-html/src/rules.rs +++ b/crates/typst-html/src/rules.rs @@ -790,7 +790,7 @@ const IMAGE_RULE: ShowFn = |elem, engine, styles| { attrs.push(attr::width, cast(image.width())); attrs.push(attr::height, cast(image.height())); - let mut css = css::Properties::build((engine, elem.span())); + let mut css = css::Properties::build(engine.binding_guard(elem.span())); // TODO: Exclude in semantic profile. if let Some(value) = typst_svg::convert_image_scaling(image.scaling()) { diff --git a/crates/typst-html/src/typed.rs b/crates/typst-html/src/typed.rs index 1e6f24dd609f..e2e97847e046 100644 --- a/crates/typst-html/src/typed.rs +++ b/crates/typst-html/src/typed.rs @@ -16,7 +16,7 @@ use typst_library::engine::Engine; use typst_library::foundations::{ Args, Array, AutoValue, CastInfo, Content, Context, Datetime, Dict, Duration, FromValue, IntoValue, NativeFuncData, NativeFuncPtr, NativeParamInfo, NoneValue, - PositiveF64, Reflect, Scope, Str, Type, Value, + PositiveF64, Reflect, Scope, Since, Str, Type, Value, }; use typst_library::layout::{Axes, Axis, Dir, Length}; use typst_library::text::TextElem; @@ -59,6 +59,7 @@ fn create_func_data( title[0..1].make_ascii_uppercase(); title }, + since: Some(Since::Version([0, 14, 0])), docs: element.docs, def_site: None, keywords: &["typed-html"], diff --git a/crates/typst-ide-wasm/src/world.rs b/crates/typst-ide-wasm/src/world.rs index be5cdcc07b34..70d6490ee0e4 100644 --- a/crates/typst-ide-wasm/src/world.rs +++ b/crates/typst-ide-wasm/src/world.rs @@ -52,7 +52,13 @@ impl BrowserWorld { // infallible by construction. let main = file_id(DEFAULT_MAIN).expect("`main.typ` is a valid virtual path"); Self { - library: LazyHash::new(Library::builder().build()), + // No export formats are registered: this world only ever + // serves IDE queries, never exports. Upstream #8496 made the + // format bindings (`pdf.*`, `html.*`) conditional on + // registration, and pulling `typst-pdf` in would bloat the wasm + // bundle for completions the CND pipeline is the real authority + // on. + library: LazyHash::new(Library::builder([]).build()), book: LazyHash::new(FontBook::new()), fonts: Vec::new(), sources: FxHashMap::default(), @@ -164,8 +170,23 @@ impl IdeWorld for BrowserWorld { self } - fn files(&self) -> Vec { - self.sources.keys().chain(self.assets.keys()).copied().collect() + fn files(&self, base: FileId, prefix: Option<&str>) -> Vec { + // Upstream widened this in #8699: the IDE now asks for virtual paths + // relative to the file being completed, optionally narrowed by what + // the user has already typed. + let dir = base.vpath().parent(); + self.sources + .keys() + .chain(self.assets.keys()) + .map(|id| id.vpath().clone()) + .filter(|path| { + prefix.is_none_or(|prefix| { + dir.as_ref() + .map(|dir| path.relative_from(dir)) + .is_none_or(|relative| relative.starts_with(prefix)) + }) + }) + .collect() } fn packages(&self) -> &[(PackageSpec, Option)] { diff --git a/crates/typst-ide/Cargo.toml b/crates/typst-ide/Cargo.toml index 60efc8c30ab4..e9631d03ce64 100644 --- a/crates/typst-ide/Cargo.toml +++ b/crates/typst-ide/Cargo.toml @@ -27,7 +27,11 @@ unscanny = { workspace = true } [dev-dependencies] typst-assets = { workspace = true, features = ["fonts"] } +typst-bundle = { workspace = true } typst-dev-assets = { workspace = true } +typst-pdf = { workspace = true } +typst-render = { workspace = true } +typst-svg = { workspace = true } once_cell = { workspace = true } [lints] diff --git a/crates/typst-ide/src/analyze.rs b/crates/typst-ide/src/analyze.rs index 9d29357069e7..4c47f25d48d9 100644 --- a/crates/typst-ide/src/analyze.rs +++ b/crates/typst-ide/src/analyze.rs @@ -1,7 +1,7 @@ use comemo::Track; use ecow::{EcoString, EcoVec, eco_vec}; use rustc_hash::FxHashSet; -use typst::foundations::{AsOutput, Label, Styles, Value}; +use typst::foundations::{AsOutput, Label, Styles, Value, WorldBindingExt}; use typst::model::{BibliographyElem, FigureElem}; use typst::syntax::{LinkedNode, SyntaxKind, ast}; use typst_layout::PagedDocument; @@ -62,12 +62,18 @@ pub fn analyze_expr_with_fallback( } let globals = crate::utils::globals(world, node); + let guard = world.silent_binding_guard(); let value = match node.cast::()? { - ast::Expr::Ident(ident) => globals.get(&ident)?.read(), + ast::Expr::Ident(ident) => globals.get(&ident)?.read(&guard).ok()?, ast::Expr::FieldAccess(access) => match access.target() { - ast::Expr::Ident(target) => { - globals.get(&target)?.read().scope()?.get(&access.field())?.read() - } + ast::Expr::Ident(target) => globals + .get(&target)? + .read(&guard) + .ok()? + .scope()? + .get(&access.field())? + .read(&guard) + .ok()?, _ => return None, }, _ => return None, diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 7c0eb2da8690..ffbd7266a751 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -5,13 +5,14 @@ use ecow::{EcoString, eco_format}; use rustc_hash::FxHashSet; use serde::{Deserialize, Serialize}; use typst::foundations::{ - AsOutput, AutoValue, CastInfo, Func, Label, NativeElement, NoneValue, Output, - ParamInfo, Repr, StyleChain, Styles, Type, Value, fields_on, repr, + AsOutput, AutoValue, CastInfo, Func, Label, NoneValue, Output, ParamInfo, Repr, + SilentBindingGuard, StyleChain, Styles, Type, Value, WorldBindingExt, fields_on, + repr, }; use typst::layout::{Alignment, Dir}; use typst::syntax::ast::AstNode; use typst::syntax::{ - FileId, LinkedNode, Side, Source, SyntaxKind, SyntaxMode, ast, is_id_continue, + LinkedNode, Side, Source, SyntaxKind, SyntaxMode, VirtualPath, ast, is_id_continue, is_id_start, is_ident, }; use typst::text::{FontFlags, RawElem}; @@ -177,17 +178,20 @@ fn field_access_completions( // Autocomplete methods from the element's or type's scope. We only complete // those which have a `self` parameter. for (name, binding) in scopes.flat_map(|scope| scope.iter()) { - let Ok(func) = binding.read().clone().cast::() else { continue }; - if let Some(param) = func.params().next() + if let Ok(value) = binding.read(ctx.binding_guard()) + && let Ok(func) = value.clone().cast::() + && let Some(param) = func.params().next() && param.name() == Some("self") { - ctx.call_completion(name.clone(), binding.read()); + ctx.call_completion(name.clone(), value); } } if let Some(scope) = value.scope() { for (name, binding) in scope.iter() { - ctx.call_completion(name.clone(), binding.read()); + if let Ok(value) = binding.read(ctx.binding_guard()) { + ctx.call_completion(name.clone(), value); + } } } @@ -197,7 +201,9 @@ fn field_access_completions( // with method syntax; // 2. We can unwrap the field's value since it's a field belonging to // this value's type, so accessing it should not fail. - ctx.value_completion(field, &value.field(field, ()).unwrap()); + if let Ok(value) = value.field(field, ctx.binding_guard()) { + ctx.value_completion(field, &value); + } } match value { @@ -322,8 +328,10 @@ fn import_item_completions<'a>( } for (name, binding) in scope.iter() { - if existing.iter().all(|item| item.original_name().as_str() != name) { - ctx.value_completion(name.clone(), binding.read()); + if existing.iter().all(|item| item.original_name().as_str() != name) + && let Ok(value) = binding.read(ctx.binding_guard()) + { + ctx.value_completion(name.clone(), value); } } } @@ -617,8 +625,7 @@ fn path_completion(func: &Func, param: &ParamInfo) -> Option<&'static [&'static (Some("cite"), "style") => &["csl"], (Some("raw"), "syntaxes") => &["sublime-syntax"], (Some("raw"), "theme") => &["tmtheme"], - (Some("attach"), "path") if *func == typst::pdf::AttachElem::ELEM => &[], - (None, "path") => &[], + (_, "path") => &[], _ => return None, }) } @@ -1154,16 +1161,19 @@ impl<'a> CompletionContext<'a> { } /// Add completions for all available files. - fn file_completions(&mut self, mut filter: impl FnMut(FileId) -> bool) { + fn file_completions(&mut self, mut filter: impl FnMut(&VirtualPath) -> bool) { let Some(current_id) = self.leaf.span().id() else { return }; let Some(current_dir) = current_id.vpath().parent() else { return }; + let prefix = + Some(self.before[self.from..].trim_matches('\"')).filter(|s| !s.is_empty()); + let mut paths: Vec = self .world - .files() + .files(current_id, prefix) .iter() - .filter(|&&id| id != current_id && filter(id)) - .map(|id| id.vpath().relative_from(¤t_dir)) + .filter(|&path| path != current_id.vpath() && filter(path)) + .map(|path| path.relative_from(¤t_dir)) .collect(); paths.sort(); @@ -1180,9 +1190,8 @@ impl<'a> CompletionContext<'a> { if extensions.is_empty() { self.file_completions(|_| true); } - self.file_completions(|id| { - let ext = id - .vpath() + self.file_completions(|path| { + let ext = path .extension() .map(EcoString::from) .unwrap_or_default() @@ -1424,12 +1433,13 @@ impl<'a> CompletionContext<'a> { /// Add completions for definitions that are available at the cursor. /// /// Filters the global/math scope with the given filter. - fn scope_completions(&mut self, parens: bool, filter: impl Fn(&Value) -> bool) { + fn scope_completions(&mut self, parens: bool, filter_fn: impl Fn(&Value) -> bool) { // When any of the constituent parts of the value matches the filter, // that's ok as well. For example, when autocompleting `#rect(fill: |)`, // we propose colors, but also dictionaries and modules that contain // colors. - let filter = |value: &Value| check_value_recursively(value, &filter); + let guard = self.binding_guard(); + let filter = |value: &Value| check_value_recursively(&guard, value, &filter_fn); let mut defined = BTreeMap::>::new(); named_items(self.world, self.leaf.clone(), |item| { @@ -1455,12 +1465,18 @@ impl<'a> CompletionContext<'a> { } for (name, binding) in globals(self.world, self.leaf).iter() { - let value = binding.read(); - if filter(value) && !defined.contains_key(name) { + if let Ok(value) = binding.read(self.binding_guard()) + && filter(value) + && !defined.contains_key(name) + { self.value_completion_full(Some(name.clone()), value, parens, None, None); } } } + + fn binding_guard(&self) -> SilentBindingGuard { + self.world.silent_binding_guard() + } } /// What kind of parentheses to autocomplete for a function. @@ -1872,6 +1888,7 @@ mod tests { .with_source("content/c.typ", "#include \"\"") .with_source("content/d.typ", "#pdf.attach(\"\")") .with_source("content/e.typ", "#math.attach(\"\")") + .with_source("content/f.typ", "#read(\"\")") .with_asset_at("assets/tiger.jpg", "tiger.jpg") .with_asset_at("assets/rhino.png", "rhino.png") .with_asset_at("data/example.csv", "example.csv"); @@ -1893,7 +1910,45 @@ mod tests { test(&world, ("content/d.typ", -2)) .must_include([q!("../assets/tiger.jpg"), q!("../data/example.csv")]); - test(&world, ("content/e.typ", -2)).must_exclude([q!("data/example.csv")]); + test(&world, ("content/e.typ", -2)).must_exclude([q!("../data/example.csv")]); + + test(&world, ("content/f.typ", -2)) + .must_include([ + q!("a.typ"), + q!("../assets/tiger.jpg"), + q!("../data/example.csv"), + ]) + .must_exclude([q!("f.typ")]); + } + + #[test] + fn test_autocomplete_file_path_with_prefix() { + let world = TestWorld::new("") + .with_source("content/main.typ", "#read(\"a\")") + .with_source("content/test1.typ", "#read(\"\")") + .with_source("content/test2.typ", "#read(\"..\")") + .with_source("content/test3.typ", "#read(\"../assets/r\")") + .with_source("content/aux.typ", "") + .with_asset_at("assets/tiger.jpg", "tiger.jpg") + .with_asset_at("assets/rhino.png", "rhino.png"); + + test(&world, ("content/main.typ", -3)) + .must_include([q!("aux.typ")]) + .must_exclude([q!("test.typ")]); + + test(&world, ("content/test1.typ", -3)).must_include([ + q!("aux.typ"), + q!("../assets/tiger.jpg"), + q!("../assets/tiger.jpg"), + ]); + + test(&world, ("content/test2.typ", -3)) + .must_include([q!("../assets/rhino.png"), q!("../assets/tiger.jpg")]) + .must_exclude([q!("aux.typ")]); + + test(&world, ("content/test3.typ", -3)) + .must_include([q!("../assets/rhino.png")]) + .must_exclude([q!("aux.typ"), q!("../assets/tiger.jpg")]); } #[test] diff --git a/crates/typst-ide/src/definition.rs b/crates/typst-ide/src/definition.rs index d4f9fd51038a..5c729a967c8b 100644 --- a/crates/typst-ide/src/definition.rs +++ b/crates/typst-ide/src/definition.rs @@ -1,4 +1,4 @@ -use typst::foundations::{AsOutput, Label, Selector, Value}; +use typst::foundations::{AsOutput, Label, Selector, Value, WorldBindingExt}; use typst::syntax::{FileId, LinkedNode, Side, Source, Span, ast}; use typst::utils::PicoStr; @@ -56,8 +56,10 @@ pub fn definition( } } - if let Some(binding) = globals(world, &leaf).get(&name) { - return Some(Definition::Std(binding.read().clone())); + if let Some(binding) = globals(world, &leaf).get(&name) + && let Ok(value) = binding.read(world.silent_binding_guard()) + { + return Some(Definition::Std(value.clone())); } } diff --git a/crates/typst-ide/src/docs.rs b/crates/typst-ide/src/docs.rs index 726795144e36..5b589ec1e40f 100644 --- a/crates/typst-ide/src/docs.rs +++ b/crates/typst-ide/src/docs.rs @@ -76,7 +76,12 @@ impl Docs { lines.push(text.strip_prefix('/').unwrap_or(text)); } else if let Some(comment) = prev.get().cast::() { lines.push(comment.text()); - } else if !matches!(prev.kind(), SyntaxKind::Space | SyntaxKind::Hash) { + } else if !matches!( + prev.kind(), + SyntaxKind::SpaceNoNewline + | SyntaxKind::SpaceWithNewline + | SyntaxKind::Hash + ) { break; } current = prev; diff --git a/crates/typst-ide/src/lib.rs b/crates/typst-ide/src/lib.rs index 174a4baba668..25ba2824900b 100644 --- a/crates/typst-ide/src/lib.rs +++ b/crates/typst-ide/src/lib.rs @@ -18,8 +18,8 @@ pub use self::tooltip::{Tooltip, tooltip}; use ecow::EcoString; use typst::World; -use typst::syntax::FileId; use typst::syntax::package::PackageSpec; +use typst::syntax::{FileId, VirtualPath}; /// Extends the `World` for IDE functionality. pub trait IdeWorld: World { @@ -41,11 +41,18 @@ pub trait IdeWorld: World { &[] } - /// Returns a list of all known files. + /// Returns a list of relevant path completion candidates. + /// + /// The `base` parameter refers to the source file where the completion is + /// being performed, and `prefix` contains an already entered path to be + /// extended (if any). Implementations may use either piece of information + /// at their discretion to narrow down the list of candidates, i.e. for + /// relevance or performance. /// /// This function is **optional** to implement. It enhances the user /// experience by enabling autocompletion for file paths. - fn files(&self) -> Vec { + #[allow(unused_variables)] + fn files(&self, base: FileId, prefix: Option<&str>) -> Vec { vec![] } } diff --git a/crates/typst-ide/src/matchers.rs b/crates/typst-ide/src/matchers.rs index eb20f290e394..4c95ff9d621c 100644 --- a/crates/typst-ide/src/matchers.rs +++ b/crates/typst-ide/src/matchers.rs @@ -1,5 +1,5 @@ use ecow::EcoString; -use typst::foundations::{Module, Value}; +use typst::foundations::{Module, Value, WorldBindingExt}; use typst::syntax::ast::AstNode; use typst::syntax::{LinkedNode, Span, SyntaxKind, ast}; @@ -77,11 +77,14 @@ pub fn named_items( Some(ast::Imports::Wildcard) => { if let Some(scope) = source_value.and_then(Value::scope) { for (name, binding) in scope.iter() { - let item = NamedItem::Import( - name, - binding.span(), - Some(binding.read()), - ); + let Ok(value) = + binding.read(world.silent_binding_guard()) + else { + continue; + }; + + let item = + NamedItem::Import(name, binding.span(), Some(value)); if let Some(res) = recv(item) { return Some(res); } @@ -101,13 +104,20 @@ pub fn named_items( for ident in iter { binding = binding.and_then(|binding| { - binding.read().scope()?.get(&ident) + binding + .read(world.silent_binding_guard()) + .ok()? + .scope()? + .get(&ident) }); } let bound = item.bound_name(); let (span, value) = match binding { - Some(binding) => (binding.span(), Some(binding.read())), + Some(binding) => ( + binding.span(), + binding.read(world.silent_binding_guard()).ok(), + ), None => (bound.span(), None), }; diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index bac78762ecb9..963b71419379 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -117,10 +117,20 @@ impl IdeWorld for TestWorld { self } - fn files(&self) -> Vec { + fn files(&self, base: FileId, prefix: Option<&str>) -> Vec { + let dir = base.vpath().parent(); + std::iter::once(self.main.id()) .chain(self.files.sources.keys().copied()) .chain(self.files.assets.keys().copied()) + .map(|id| id.vpath().clone()) + .filter(|path| { + prefix.is_none_or(|prefix| { + dir.as_ref() + .map(|dir| path.relative_from(dir)) + .is_none_or(|relative| relative.starts_with(prefix)) + }) + }) .collect() } @@ -175,7 +185,15 @@ fn library() -> Library { // Set page width to 120pt with 10pt margins, so that the inner page is // exactly 100pt wide. Page height is unbounded and font size is 10pt so // that it multiplies to nice round numbers. - let mut lib = typst::Library::builder().with_features(Features::all()).build(); + let mut lib = typst::Library::builder([ + typst_html::FORMAT, + typst_pdf::FORMAT, + typst_svg::FORMAT, + typst_render::FORMAT, + typst_bundle::FORMAT, + ]) + .with_features(Features::all()) + .build(); lib.styles.set(PageElem::width, Smart::Custom(Abs::pt(120.0).into())); lib.styles.set(PageElem::height, Smart::Auto); lib.styles.set( diff --git a/crates/typst-ide/src/utils.rs b/crates/typst-ide/src/utils.rs index 2763d9e34760..c30634207dad 100644 --- a/crates/typst-ide/src/utils.rs +++ b/crates/typst-ide/src/utils.rs @@ -5,7 +5,7 @@ use comemo::Track; use ecow::EcoString; use indexmap::IndexMap; use typst::engine::{Engine, Route, Sink, Traced}; -use typst::foundations::{Scope, Value}; +use typst::foundations::{Scope, SilentBindingGuard, Value}; use typst::introspection::EmptyIntrospector; use typst::syntax::{LinkedNode, SyntaxMode}; use typst::text::{ @@ -184,10 +184,11 @@ pub fn globals<'a>(world: &'a dyn IdeWorld, leaf: &LinkedNode) -> &'a Scope { /// Checks whether the given value or any of its constituent parts satisfy the /// predicate. pub fn check_value_recursively( + guard: &SilentBindingGuard, value: &Value, predicate: impl Fn(&Value) -> bool, ) -> bool { - let mut searcher = Searcher { steps: 0, predicate, max_steps: 1000 }; + let mut searcher = Searcher { guard, steps: 0, predicate, max_steps: 1000 }; match searcher.find(value) { ControlFlow::Break(matching) => matching, ControlFlow::Continue(()) => false, @@ -196,13 +197,14 @@ pub fn check_value_recursively( /// Recursively searches for a value that passes the filter, but without /// exceeding a maximum number of search steps. -struct Searcher { +struct Searcher<'a, F> { + guard: &'a SilentBindingGuard, max_steps: usize, steps: usize, predicate: F, } -impl Searcher +impl Searcher<'_, F> where F: Fn(&Value) -> bool, { @@ -225,7 +227,13 @@ where self.find_iter(content.fields().iter().map(|(_, v)| v))?; } Value::Module(module) => { - self.find_iter(module.scope().iter().map(|(_, b)| b.read()))?; + let guard = self.guard; + self.find_iter( + module + .scope() + .iter() + .filter_map(move |(_name, binding)| binding.read(guard).ok()), + )?; } _ => {} } diff --git a/crates/typst-kit/src/downloader.rs b/crates/typst-kit/src/downloader.rs index c2ad07e5045a..cac8750f04d0 100644 --- a/crates/typst-kit/src/downloader.rs +++ b/crates/typst-kit/src/downloader.rs @@ -138,8 +138,20 @@ impl SystemDownloader { self.cert_path.as_ref().map(|path| { self.cert.get_or_try_init(|| { - let pem = std::fs::read(path)?; - Certificate::from_pem(&pem).map_err(io::Error::other) + #[cfg(all(target_vendor = "apple", not(target_os = "macos")))] + { + let _ = path; + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "custom PEM certificates are not supported by native-tls on this platform", + )); + } + + #[cfg(not(all(target_vendor = "apple", not(target_os = "macos"))))] + { + let pem = std::fs::read(path)?; + Certificate::from_pem(&pem).map_err(io::Error::other) + } }) }) } diff --git a/crates/typst-kit/src/server.rs b/crates/typst-kit/src/server.rs index 1d4ee95d8306..ab3839b6a0b9 100644 --- a/crates/typst-kit/src/server.rs +++ b/crates/typst-kit/src/server.rs @@ -13,7 +13,7 @@ use tiny_http::{Header, Request, Response, StatusCode}; use typst_library::diag::{StrResult, bail}; use typst_library::foundations::Bytes; -type Router = Box Option + Send + Sync>; +type Router = Box Result + Send + Sync>; type RouterBucket = Bucket; /// Serves HTML with live reload. @@ -55,32 +55,44 @@ impl HttpServer { #[cfg(feature = "bundle")] pub fn set_bundle(&self, bundle: typst_bundle::Bundle, fs: typst_bundle::VirtualFs) { self.set_router(move |route| { - let path = typst_syntax::VirtualPath::new(route).ok()?; - let with_index = path.join("index.html").unwrap(); - for path in [path, with_index] { - let Some(data) = fs.get(&path) else { continue }; - let body = if matches!( - bundle.files.get(&path), + let read = |path| { + let data = fs.get(path)?; + if matches!( + bundle.files.get(path), Some(typst_bundle::BundleFile::Document( typst_bundle::BundleDocument::Html(_) )) ) && let Ok(string) = data.as_str() { - HttpBody::Html(string.to_owned()) + Some(HttpBody::Html(string.to_owned())) } else { - HttpBody::Raw(data.clone()) - }; - return Some(body); + Some(HttpBody::Raw(data.clone())) + } + }; + + let path = typst_syntax::VirtualPath::new(route) + .map_err(|_| RoutingError::NotFound)?; + + if let Some(body) = read(&path) { + return Ok(body); + } + + let with_index = path.join("index.html").unwrap(); + if let Some(body) = read(&with_index) { + if !route.ends_with('/') { + return Err(RoutingError::Moved(route.to_string() + "/")); + } + return Ok(body); } - None + Err(RoutingError::NotFound) }); } /// Updates the content handler, triggering a reload in all connected browsers. pub fn set_router(&self, router: R) where - R: Fn(&str) -> Option + Send + Sync + 'static, + R: Fn(&str) -> Result + Send + Sync + 'static, { self.bucket.put(Box::new(router)); } @@ -88,10 +100,17 @@ impl HttpServer { /// Creates a handler that serves just one HTML page at `/`. fn html_single_fs(html: String) -> Router { - Box::new(move |route| (route == "/").then(|| HttpBody::Html(html.clone()))) + Box::new(move |route| { + if route == "/" { + Ok(HttpBody::Html(html.clone())) + } else { + Err(RoutingError::NotFound) + } + }) } -/// Something that can be served by the [`HttpServer`]. +/// Something that can be yielded by a [router](HttpServer::set_router). +#[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum HttpBody { /// An HTML page. /// @@ -102,6 +121,16 @@ pub enum HttpBody { Raw(Bytes), } +/// An error response that can be returned by a [router](HttpServer::set_router). +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub enum RoutingError { + /// The requested route was not found. + NotFound, + /// The requested route was moved to the provided one. That route should be + /// requested instead. + Moved(String), +} + /// Starts a local HTTP server. /// /// Uses the specified port or tries to find a free port in the range @@ -157,11 +186,14 @@ fn handle(req: Request, reload: bool, bucket: &Arc) -> io::Result< } let fs = bucket.get(); - let Some(body) = fs(path.as_ref()) else { - return req.respond(Response::empty(StatusCode(404))); - }; - - handle_body(req, reload, body) + match fs(path.as_ref()) { + Ok(body) => handle_body(req, reload, body), + Err(RoutingError::Moved(to)) => req.respond( + Response::empty(StatusCode(301)) + .with_header(Header::from_bytes("Location", to).unwrap()), + ), + Err(RoutingError::NotFound) => req.respond(Response::empty(StatusCode(404))), + } } /// Handles for the `/` route. Serves the compiled HTML. @@ -225,17 +257,88 @@ fn inject_live_reload_script(html: &mut String) { html.insert_str(pos, LIVE_RELOAD_SCRIPT); } -/// Selects a MIME type for a request based on path and data. +/// Selects a MIME type for a request based on file extension and/or data. +/// +/// First, tries to find a match for the file extension and if that yields +/// nothing, inspects the data itself. fn select_mime_type(path: &str, buf: &[u8]) -> Option<&'static str> { - match path.rsplit_once('.').map(|(_, r)| r) { - Some("html") => Some("text/html"), - Some("pdf") => Some("application/pdf"), - Some("png") => Some("image/png"), - Some("svg") => Some("image/svg+xml"), - Some("css") => Some("text/css"), - Some("js") => Some("text/javascript"), - _ => infer::get(buf).map(|ty| ty.mime_type()), - } + path.rsplit_once('.') + .and_then(|(_, r)| select_mime_type_by_extension(r)) + .or_else(|| infer::get(buf).map(|ty| ty.mime_type())) +} + +/// Selects a MIME type for a request based on the file extension. +/// +/// See for the +/// full list of standardized MIME types. This function only handles a subset of +/// this list. +/// +/// Additionally, it handles a few very commonly used unstandardized types. +fn select_mime_type_by_extension(ext: &str) -> Option<&'static str> { + Some(match ext.to_lowercase().as_str() { + // Web. + "html" | "htm" => "text/html", + "xhtml" => "application/xhtml+xml", + "css" => "text/css", + "js" | "mjs" => "text/javascript", + "wasm" => "application/wasm", + + // Document. + "typ" => "text/vnd.typst", + "txt" => "text/plain", + "pdf" => "application/pdf", + "md" => "text/markdown", + + // Font. + "ttc" | "otc" => "font/collection", + "ttf" => "font/ttf", + "otf" => "font/otf", + "woff" => "font/woff", + "woff2" => "font/woff2", + + // Container. + "zip" => "application/zip", + // Not standardized by IANA. + "tar" => "application/x-tar", + + // Data. + // This file extension is not specified, but commonly used. + "bin" => "application/octet-stream", + "csv" => "text/csv", + // RFC 7303, § 4.1 recommends `application/` over `text/`. + "xml" => "application/xml", + "json" => "application/json", + "yaml" | "yml" => "application/yaml", + + // Image. + "avif" => "image/avif", + "bmp" => "image/bmp", + "gif" => "image/gif", + "ico" => "image/vnd.microsoft.icon", + "jpg" | "jpeg" => "image/jpeg", + "jxl" => "image/jxl", + "png" => "image/png", + "svg" => "image/svg+xml", + "tif" | "tiff" => "image/tiff", + "webp" => "image/webp", + + // Audio. + "mp3" => "audio/mpeg", + "ogg" | "oga" | "spx" | "opus" => "audio/ogg", + // Not standardized by IANA and there are other common variations (e.g. + // `audio/x-wav` and `audio/vnd.wave`), but this one seems most commonly + // in use. + "wav" => "audio/wav", + + // Video. + "mp4" => "video/mp4", + "mpeg" => "video/mpeg", + "ogv" => "video/ogg", + // Not standardized by IANA, but this type is practically in use. + "webm" => "video/webm", + + _ => return None, + }) } /// Holds data and notifies consumers when it's updated. diff --git a/crates/typst-layout/src/document.rs b/crates/typst-layout/src/document.rs index b075c20feafe..a615d1c5c89c 100644 --- a/crates/typst-layout/src/document.rs +++ b/crates/typst-layout/src/document.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use ecow::EcoVec; use typst_library::diag::SourceResult; use typst_library::engine::Engine; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{Content, Output, Smart, StyleChain, Target}; use typst_library::introspection::Introspector; use typst_library::layout::{Abs, Frame, Sides}; @@ -17,6 +18,7 @@ use crate::PagedIntrospector; pub struct PagedDocument { pages: EcoVec, info: DocumentInfo, + options: DocumentFormatOptions, introspector: Arc, } @@ -24,9 +26,18 @@ impl PagedDocument { /// Creates a new paged document from its parts. /// /// Internally builds the introspector. - pub fn new(pages: EcoVec, info: DocumentInfo) -> Self { + pub fn new( + pages: EcoVec, + info: DocumentInfo, + options: DocumentFormatOptions, + ) -> Self { let introspector = PagedIntrospector::new(&pages); - Self { pages, info, introspector: Arc::new(introspector) } + Self { + pages, + info, + options, + introspector: Arc::new(introspector), + } } /// The document's finished pages. @@ -58,6 +69,10 @@ impl Document for PagedDocument { fn info(&self) -> &DocumentInfo { &self.info } + + fn options(&self) -> &DocumentFormatOptions { + &self.options + } } impl Output for PagedDocument { diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs index 089025b8fb9b..80d09a34fce1 100644 --- a/crates/typst-layout/src/flow/collect.rs +++ b/crates/typst-layout/src/flow/collect.rs @@ -41,21 +41,19 @@ pub fn collect<'a>( Collector { engine, bump, - children, locator: locator.split(), base, expand, output: Vec::with_capacity(children.len()), par_situation: ParSituation::First, } - .run(mode) + .run(children, mode) } /// State for collection. struct Collector<'a, 'x, 'y> { engine: &'x mut Engine<'y>, bump: &'a Bump, - children: &'x [Pair<'a>], base: Size, expand: bool, locator: SplitLocator<'a>, @@ -65,16 +63,36 @@ struct Collector<'a, 'x, 'y> { impl<'a> Collector<'a, '_, '_> { /// Perform the collection. - fn run(self, mode: FlowMode) -> SourceResult>> { + fn run( + mut self, + children: &[Pair<'a>], + mode: FlowMode, + ) -> SourceResult>> { + // Extract leading and trailing tags. + let (start, end) = children.split_prefix_suffix(|(c, _)| c.is::()); + let inner = &children[start..end]; + + for (c, _) in &children[..start] { + let elem = c.to_packed::().unwrap(); + self.output.push(Child::Tag(&elem.tag)); + } + match mode { - FlowMode::Root | FlowMode::Block => self.run_block(), - FlowMode::Inline => self.run_inline(), + FlowMode::Root | FlowMode::Block => self.run_block(inner)?, + FlowMode::Inline => self.run_inline(inner)?, } + + for (c, _) in &children[end..] { + let elem = c.to_packed::().unwrap(); + self.output.push(Child::Tag(&elem.tag)); + } + + Ok(self.output) } /// Perform collection for block-level children. - fn run_block(mut self) -> SourceResult>> { - for &(child, styles) in self.children { + fn run_block(&mut self, children: &[Pair<'a>]) -> SourceResult<()> { + for &(child, styles) in children { if let Some(elem) = child.to_packed::() { self.output.push(Child::Tag(&elem.tag)); } else if let Some(elem) = child.to_packed::() { @@ -82,7 +100,8 @@ impl<'a> Collector<'a, '_, '_> { } else if let Some(elem) = child.to_packed::() { self.par(elem, styles)?; } else if let Some(elem) = child.to_packed::() { - self.block(elem, styles); + let alone = children.len() == 1; + self.block(elem, styles, alone); } else if let Some(elem) = child.to_packed::() { self.place(elem, styles)?; } else if child.is::() { @@ -103,23 +122,18 @@ impl<'a> Collector<'a, '_, '_> { )); } } - - Ok(self.output) + Ok(()) } /// Perform collection for inline-level children. - fn run_inline(mut self) -> SourceResult>> { - // Extract leading and trailing tags. - let (start, end) = self.children.split_prefix_suffix(|(c, _)| c.is::()); - let inner = &self.children[start..end]; - + fn run_inline(&mut self, children: &[Pair<'a>]) -> SourceResult<()> { // Compute the shared styles. - let styles = StyleChain::trunk_from_pairs(inner).unwrap_or_default(); + let styles = StyleChain::trunk_from_pairs(children).unwrap_or_default(); // Layout the lines. let lines = crate::inline::layout_inline( self.engine, - inner, + children, &mut self.locator, styles, self.base, @@ -127,20 +141,10 @@ impl<'a> Collector<'a, '_, '_> { )? .into_frames(); - for (c, _) in &self.children[..start] { - let elem = c.to_packed::().unwrap(); - self.output.push(Child::Tag(&elem.tag)); - } - let leading = styles.resolve(ParElem::leading); self.lines(lines, leading, styles); - for (c, _) in &self.children[end..] { - let elem = c.to_packed::().unwrap(); - self.output.push(Child::Tag(&elem.tag)); - } - - Ok(self.output) + Ok(()) } /// Collect vertical spacing into a relative or fractional child. @@ -231,10 +235,14 @@ impl<'a> Collector<'a, '_, '_> { /// Collect a block into a [`SingleChild`] or [`MultiChild`] depending on /// whether it is breakable. - fn block(&mut self, elem: &'a Packed, styles: StyleChain<'a>) { + fn block( + &mut self, + elem: &'a Packed, + styles: StyleChain<'a>, + alone: bool, + ) { let locator = self.locator.next(&elem.span()); let align = styles.resolve(AlignElem::alignment); - let alone = self.children.len() == 1; let sticky = elem.sticky.get(styles); let breakable = elem.breakable.get(styles); let fr = match elem.height.get(styles) { diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs index 5cbd0f4d1258..a89079f2a3a8 100644 --- a/crates/typst-layout/src/flow/compose.rs +++ b/crates/typst-layout/src/flow/compose.rs @@ -1,6 +1,8 @@ +use std::convert::Infallible; use std::num::NonZeroUsize; -use typst_library::diag::SourceResult; +use ecow::EcoVec; +use typst_library::diag::{SourceDiagnostic, SourceResult}; use typst_library::engine::Engine; use typst_library::foundations::{Content, NativeElement, Packed, Resolve, Smart}; use typst_library::introspection::{ @@ -11,24 +13,137 @@ use typst_library::layout::{ Abs, Axes, Dir, FixedAlignment, Fragment, Frame, FrameItem, FrameParent, Inherit, OuterHAlignment, PlacementScope, Point, Region, Regions, Rel, Size, }; +use typst_library::model::ArtifactKind; use typst_library::model::{ FootnoteElem, FootnoteEntry, LineNumberingScope, Numbering, ParLineMarker, }; -use typst_library::pdf::ArtifactKind; use typst_syntax::Span; use typst_utils::{NonZeroExt, Numeric}; -use super::{ - Config, FlowMode, FlowResult, LineNumberConfig, PlacedChild, Stop, Work, distribute, -}; +use super::distribute::distribute; +use super::{Config, FlowMode, LineNumberConfig, PlacedChild, Work}; + +/// A control flow event during layout. +/// +/// The type parameter `R` describes the requested relayout. +pub(super) enum RelayoutStop { + /// Indicates that the given scope should be relayouted. + Relayout(R), + /// A fatal error. + Error(EcoVec), +} + +impl From> for RelayoutStop { + fn from(error: EcoVec) -> Self { + Self::Error(error) + } +} + +/// A version of [`PlacementScope`] that is known to be `Parent`. +pub struct ParentScope; + +impl From for PlacementScope { + fn from(_: ParentScope) -> Self { + PlacementScope::Parent + } +} + +/// A version of [`PlacementScope`] that is known to be `Column`. +pub struct ColumnScope; + +impl From for PlacementScope { + fn from(_: ColumnScope) -> Self { + PlacementScope::Column + } +} + +/// A control flow event during footnote handling. +pub(super) type FootnoteStop = InsertionStop; + +/// A control flow event during float handling. +pub(super) type FloatStop = InsertionStop; + +/// A control flow event while handling an insertion. +/// +/// The type parameter `R` describes the requested relayout. The type parameter +/// `M` controls the [`MigrateOrigin`](Self::MigrateOrigin) variant. When +/// `M = Infallible`, the variant is empty. +pub(super) enum InsertionStop { + /// The insertion changed the available space. + Relayout(R), + /// The footnote did not fit with its origin frame. + MigrateOrigin(M), + /// A fatal error. + Error(EcoVec), +} + +impl From> for FloatStop { + fn from(stop: FootnoteStop) -> Self { + match stop { + FootnoteStop::Relayout(ColumnScope) => Self::Relayout(PlacementScope::Column), + FootnoteStop::MigrateOrigin(migration) => Self::MigrateOrigin(migration), + FootnoteStop::Error(error) => Self::Error(error), + } + } +} + +impl From> for RelayoutStop +where + R: Into, +{ + fn from(stop: InsertionStop) -> Self { + match stop { + InsertionStop::Relayout(r) => Self::Relayout(r.into()), + InsertionStop::MigrateOrigin(never) => match never {}, + InsertionStop::Error(error) => Self::Error(error), + } + } +} + +impl From> for InsertionStop { + fn from(error: EcoVec) -> Self { + Self::Error(error) + } +} + +/// Whether a footnote's origin frame may migrate to the next region. +#[derive(Clone, Copy)] +pub(super) struct Migration(Option); + +impl Migration<()> { + /// Allow the origin frame to migrate. + pub(super) const ALLOW: Self = Self(Some(())); +} + +impl Migration { + /// Forbid the origin frame from migrating. + const FORBID: Self = Self(None); +} + +impl Migration { + /// Forbid the origin frame from migrating. + fn forbid(&mut self) { + self.0 = None; + } + + /// Disable migration and return the previous setting. + fn take(&mut self) -> Self { + Self(self.0.take()) + } + + /// Extract the inner value, if migration is allowed. + fn into_inner(self) -> Option { + self.0 + } +} /// Composes the contents of a single page/region. A region can have multiple /// columns/subregions. /// /// The composer is primarily concerned with layout of out-of-flow insertions -/// (floats and footnotes). It does this in per-page and per-column loops that -/// rerun when a new float is added (since it affects the regions available to -/// the distributor). +/// (floats and footnotes). It does this in per-page and per-column loops that +/// rerun when an insertion affects the regions available to the distributor, +/// or when balancing columns. /// /// To lay out the in-flow contents of individual subregions, the composer /// invokes [distribution](distribute()). @@ -46,6 +161,7 @@ pub fn compose( column: 0, page_insertions: Insertions::default(), column_insertions: Insertions::default(), + column_balancing_height: None, work, footnote_spill: None, footnote_queue: vec![], @@ -69,6 +185,7 @@ pub struct Composer<'a, 'b, 'x, 'y> { page_base: Size, page_insertions: Insertions<'a, 'b>, column_insertions: Insertions<'a, 'b>, + column_balancing_height: Option, // These are here because they have to survive relayout (we could lose the // footnotes otherwise). For floats, we revisit them anyway, so it's okay to // use `work.floats` directly. This is not super clean; probably there's a @@ -81,7 +198,8 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { /// Lay out a container/page region, including container/page insertions. fn page(mut self, locator: Locator, regions: Regions) -> SourceResult { // This loop can restart region layout when requested to do so by a - // `Stop`. This happens when there is a parent-scoped float. + // `RelayoutStop::Relayout(ParentScope)`. This happens when there is a + // parent-scoped float or when balancing columns. let checkpoint = self.work.clone(); let output = loop { // Shrink the available space by the space used by page @@ -91,24 +209,26 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { match self.page_contents(locator.relayout(), pod) { Ok(frame) => break frame, - Err(Stop::Finish(_)) => unreachable!(), - Err(Stop::Relayout(PlacementScope::Column)) => unreachable!(), - Err(Stop::Relayout(PlacementScope::Parent)) => { + Err(RelayoutStop::Relayout(ParentScope)) => { *self.work = checkpoint.clone(); } - Err(Stop::Error(err)) => return Err(err), + Err(RelayoutStop::Error(err)) => return Err(err), } }; drop(checkpoint); - Ok(self.page_insertions.finalize(self.work, self.config, output)) + Ok(self.page_insertions.finalize(self.work, self.config, output, None)) } /// Lay out the inner contents of a container/page. - fn page_contents(&mut self, locator: Locator, regions: Regions) -> FlowResult { + fn page_contents( + &mut self, + locator: Locator, + regions: Regions, + ) -> Result> { // No point in create column regions, if there's just one! if self.config.columns.count == 1 { - return self.column(locator, regions); + return self.column(locator, regions).map(|(frame, ..)| frame); } // Create a backlog for multi-column layout. @@ -136,23 +256,56 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { let mut output = Frame::hard(size); let mut offset = Abs::zero(); let mut locator = locator.split(); + let mut total_used_height = Abs::zero(); + let mut last_column_separator_height = None; // Lay out the columns and stitch them together. for i in 0..self.config.columns.count { self.column = i; - let frame = self.column(locator.next(&()), inner)?; + let (frame, used_height, column_separator_height) = + self.column(locator.next(&()), inner)?; + total_used_height += used_height; if !regions.expand.y { output.size_mut().y.set_max(frame.height()); } + // Column separator between this and the previous column. + if let Some(separator) = &self.config.columns.separator + && let Some(last_separator_height) = last_column_separator_height + && !column_separator_height.min(last_separator_height).is_zero() + { + let mid = offset + 0.5 * self.config.columns.gutter; + let x = if self.config.columns.dir == Dir::LTR { + mid + } else { + regions.size.x - mid + }; + let height = column_separator_height.max(last_separator_height); + let frame = layout_column_separator( + self.engine, + separator, + self.config, + Size::new(self.config.columns.gutter, height), + )?; + output.prepend_frame( + // Prepend so it's behind both adjacent columns. + Point::with_x(x - 0.5 * frame.width()), + frame, + ); + } + last_column_separator_height = Some(column_separator_height); + if i > 0 { + offset += self.config.columns.gutter; + } + let width = frame.width(); let x = if self.config.columns.dir == Dir::LTR { offset } else { regions.size.x - offset - width }; - offset += width + self.config.columns.gutter; + offset += width; // During distribution, the baseline of the region is set to the // baseline of the first frame - e.g., the first paragraph line. @@ -166,11 +319,30 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { inner.next(); } + // Column balancing with re-layout + if self.config.columns.balanced && self.work.done() { + let height = total_used_height / self.config.columns.count as f64; + if self.column_balancing_height.is_none_or(|h| h < height) { + self.column_balancing_height = Some(height); + return Err(RelayoutStop::Relayout(ParentScope)); + } + } + Ok(output) } /// Lay out a column, including column insertions. - fn column(&mut self, locator: Locator, regions: Regions) -> FlowResult { + /// + /// On success, returns a tuple of + /// - `0`: The laid out frame. + /// - `1`: The height actually used by the inner contents (used for column + /// balancing logic). + /// - `2`: The height the column separator (if any) should have. + fn column( + &mut self, + locator: Locator, + regions: Regions, + ) -> Result<(Frame, Abs, Abs), RelayoutStop> { // Reset column insertion when starting a new column. self.column_insertions = Insertions::default(); @@ -180,32 +352,57 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { } // This loop can restart column layout when requested to do so by a - // `Stop`. This happens when there is a column-scoped float. + // `RelayoutStop::Relayout(PlacementScope::Column)`. This happens when a + // column-scoped float or footnote is added. let checkpoint = self.work.clone(); - let inner = loop { + let (inner, used_height) = loop { // Shrink the available space by the space used by column // insertions. let mut pod = regions; pod.size.y -= self.column_insertions.height(); - match self.column_contents(pod) { - Ok(frame) => break frame, - Err(Stop::Finish(_)) => unreachable!(), - Err(Stop::Relayout(PlacementScope::Column)) => { + // For column balancing, only consider space taken by floats, not footnotes + let float_height = self.column_insertions.float_height(); + let balancing_target = if self.column < self.config.columns.count - 1 { + self.column_balancing_height.map(|h| h - float_height) + } else { + None + }; + + match self.column_contents(pod, balancing_target) { + Ok((frame, used_height)) => break (frame, used_height + float_height), + Err(RelayoutStop::Relayout(PlacementScope::Column)) => { *self.work = checkpoint.clone(); } - err => return err, + Err(RelayoutStop::Relayout(PlacementScope::Parent)) => { + return Err(RelayoutStop::Relayout(ParentScope)); + } + Err(RelayoutStop::Error(error)) => { + return Err(RelayoutStop::Error(error)); + } } }; drop(checkpoint); + let column_separator_height = if self.column_insertions.bottom_floats.is_empty() { + used_height + } else { + self.column_balancing_height + .unwrap_or(inner.height() + self.column_insertions.float_height()) + }; + self.work.footnotes.extend(self.footnote_queue.drain(..)); if let Some(spill) = self.footnote_spill.take() { self.work.footnote_spill = Some(spill); } let insertions = std::mem::take(&mut self.column_insertions); - let mut output = insertions.finalize(self.work, self.config, inner); + let mut output = insertions.finalize( + self.work, + self.config, + inner, + self.column_balancing_height, + ); // Lay out per-column line numbers. if let Some(line_config) = &self.config.line_numbers { @@ -219,7 +416,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { )?; } - Ok(output) + Ok((output, used_height, column_separator_height)) } /// Lay out the inner contents of a column. @@ -228,46 +425,50 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { /// however, we forbid footnote migration (moving the frame containing the /// footnote reference if the corresponding entry doesn't fit), allowing /// the footnote invariant to be broken, as it would require handling a - /// [`Stop::Finish`] at this point, but that is exclusively handled by the - /// distributor. - fn column_contents(&mut self, regions: Regions) -> FlowResult { + /// [`FootnoteStop::MigrateOrigin`] at this point, but that is exclusively + /// handled by the distributor. + fn column_contents( + &mut self, + regions: Regions, + balancing_target: Option, + ) -> Result<(Frame, Abs), RelayoutStop> { // Process pending footnotes. for note in std::mem::take(&mut self.work.footnotes) { - self.footnote(note, &mut regions.clone(), Abs::zero(), false)?; + self.footnote(note, &mut regions.clone(), Abs::zero(), Migration::FORBID)?; } // Process pending floats. for placed in std::mem::take(&mut self.work.floats) { - self.float(placed, ®ions, false, false)?; + self.float(placed, ®ions, false, Migration::FORBID)?; } - distribute(self, regions) + distribute(self, regions, balancing_target) } /// Lays out an item with floating placement. /// /// This is called from within [`distribute()`]. When the float fits, this - /// returns an `Err(Stop::Relayout(..))`, which bubbles all the way through - /// distribution and is handled in [`Self::page`] or [`Self::column`] - /// (depending on `placed.scope`). + /// returns an `Err(FloatStop::Relayout(..))`, which bubbles all the way + /// through distribution and is handled in [`Self::page`] or + /// [`Self::column`] (depending on `placed.scope`). /// /// When the float does not fit, it is queued into `work.floats`. The /// value of `clearance` indicates that between the float and flow content /// is needed --- it is set if there are already distributed items. /// - /// The value of `migratable` determines whether footnotes within the float + /// The value of `migration` determines whether footnotes within the float /// should be allowed to prompt its migration if they don't fit in order to /// respect the footnote invariant (entries in the same page as the - /// references), triggering [`Stop::Finish`]. This is usually `true` within - /// the distributor, as it can handle that particular flow event, and - /// `false` elsewhere. - pub fn float( + /// references), triggering [`FloatStop::MigrateOrigin`]. This is usually + /// [`Migration::ALLOW`] within the distributor, as it can handle that + /// particular flow event, and [`Migration::FORBID`] elsewhere. + pub fn float( &mut self, placed: &'b PlacedChild<'a>, regions: &Regions, clearance: bool, - migratable: bool, - ) -> FlowResult<()> { + migration: Migration, + ) -> Result<(), FloatStop> { // If the float is already processed, skip it. let loc = placed.location(); if self.skipped(loc) { @@ -315,7 +516,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { } // Handle footnotes in the float. - self.footnotes(regions, &frame, need, false, migratable)?; + self.footnotes(regions, &frame, need, false, migration)?; // Determine the float's vertical alignment. We can unwrap the inner // `Option` because `Custom(None)` is checked for during collection. @@ -340,26 +541,27 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { area.skips.push(loc); // Trigger relayout. - Err(Stop::Relayout(placed.scope)) + Err(FloatStop::Relayout(placed.scope)) } /// Lays out footnotes in the `frame` if this is the root flow and there are /// any. The value of `breakable` indicates whether the element that /// produced the frame is breakable. If not, the frame is treated as atomic. /// - /// The value of `migratable` indicates whether footnote migration should be + /// The value of `migration` indicates whether footnote migration should be /// possible (at least for the first footnote found in the frame, as it is - /// forbidden for the second footnote onwards). It is usually `true` within - /// the distributor and `false` elsewhere, as the distributor can handle - /// [`Stop::Finish`] which is returned when migration is requested. - pub fn footnotes( + /// forbidden for the second footnote onwards). It is usually + /// [`Migration::ALLOW`] within the distributor and [`Migration::FORBID`] + /// elsewhere, as the distributor can handle [`FootnoteStop::MigrateOrigin`] + /// which is returned when migration is requested. + pub fn footnotes( &mut self, regions: &Regions, frame: &Frame, flow_need: Abs, breakable: bool, - migratable: bool, - ) -> FlowResult<()> { + mut migration: Migration, + ) -> Result<(), FootnoteStop> { // Footnotes are only supported at the root level. if self.config.mode != FlowMode::Root { return Ok(()); @@ -383,7 +585,9 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // The first footnote's origin frame should be migratable if the region // may progress (already checked by the footnote function) and if the // origin frame isn't breakable (checked here). - let mut migratable = migratable && !breakable; + if breakable { + migration.forbid(); + } for (y, elem) in notes { // The amount of space used by the in-flow content that contains the @@ -391,41 +595,42 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // the marker. For an unbreakable frame, it's the full height. let flow_need = if breakable { y } else { flow_need }; + // We only migrate the origin frame if the first footnote's first + // line didn't fit. + let migration = migration.take(); + // Process the footnote. - match self.footnote(elem, &mut regions, flow_need, migratable) { + match self.footnote(elem, &mut regions, flow_need, migration) { // The footnote was already processed or queued. Ok(()) => {} // First handle more footnotes before relayouting. - Err(Stop::Relayout(_)) => relayout = true, + Err(FootnoteStop::Relayout(ColumnScope)) => relayout = true, // Either of - // - A `Stop::Finish` indicating that the frame's origin element - // should migrate to uphold the footnote invariant. + // - A `FootnoteStop::MigrateOrigin` indicating that the frame's + // origin element should migrate to uphold the footnote + // invariant. // - A fatal error. err => return err, } - - // We only migrate the origin frame if the first footnote's first - // line didn't fit. - migratable = false; } // If this is set, we laid out at least one footnote, so we need a // relayout. if relayout { - return Err(Stop::Relayout(PlacementScope::Column)); + return Err(FootnoteStop::Relayout(ColumnScope)); } Ok(()) } /// Handles a single footnote. - fn footnote( + fn footnote( &mut self, elem: Packed, regions: &mut Regions, flow_need: Abs, - migratable: bool, - ) -> FlowResult<()> { + migration: Migration, + ) -> Result<(), FootnoteStop> { // Ignore reference footnotes and already processed ones. let loc = elem.location().unwrap(); if elem.is_ref() || self.skipped(loc) { @@ -499,8 +704,10 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // `regions.may_progress()` must still be checked separately for // migration, regardless of the presence of flow need. if first.is_empty() && exist_non_empty_frame { - if migratable && regions.may_progress() { - return Err(Stop::Finish(false)); + if let Some(m) = migration.into_inner() + && regions.may_progress() + { + return Err(FootnoteStop::MigrateOrigin(m)); } else if regions.may_progress() || !flow_need.is_zero() { self.footnote_queue.push(elem); return Ok(()); @@ -525,24 +732,25 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // Lay out nested footnotes. for (_, note) in nested { - match self.footnote(note, regions, flow_need, migratable) { + match self.footnote(note, regions, flow_need, migration) { // This footnote was already processed or queued. Ok(()) => {} // Footnotes always request a relayout when processed for the // first time, so we ignore a relayout request since we're // about to do so afterwards. Without this check, the first // inner footnote interrupts processing of the following ones. - Err(Stop::Relayout(_)) => {} + Err(FootnoteStop::Relayout(ColumnScope)) => {} // Either of - // - A `Stop::Finish` indicating that the frame's origin element - // should migrate to uphold the footnote invariant. + // - A `FootnoteStop::MigrateOrigin` indicating that the frame's + // origin element should migrate to uphold the footnote + // invariant. // - A fatal error. err => return err, } } // Since we laid out a footnote, we need a relayout. - Err(Stop::Relayout(PlacementScope::Column)) + Err(FootnoteStop::Relayout(ColumnScope)) } /// Handles spillover from a footnote. @@ -626,6 +834,22 @@ fn layout_footnote( }) } +/// Lay out the column separator, typically a line. +fn layout_column_separator( + engine: &mut Engine, + separator: &Content, + config: &Config, + base: Size, +) -> SourceResult { + crate::layout_frame( + engine, + separator, + Locator::root(), + config.shared, + Region::new(base, Axes::splat(true)), + ) +} + /// An additive list of insertions. #[derive(Default)] struct Insertions<'a, 'b> { @@ -635,6 +859,7 @@ struct Insertions<'a, 'b> { footnote_separator: Option, top_size: Abs, bottom_size: Abs, + footnote_size: Abs, width: Abs, skips: Vec, } @@ -664,14 +889,14 @@ impl<'a, 'b> Insertions<'a, 'b> { /// Add a footnote to the bottom area. fn push_footnote(&mut self, config: &Config, frame: Frame) { self.width.set_max(frame.width()); - self.bottom_size += config.footnote.gap + frame.height(); + self.footnote_size += config.footnote.gap + frame.height(); self.footnotes.push(frame); } /// Add a footnote separator to the bottom area. fn push_footnote_separator(&mut self, config: &Config, frame: Frame) { self.width.set_max(frame.width()); - self.bottom_size += config.footnote.clearance + frame.height(); + self.footnote_size += config.footnote.clearance + frame.height(); self.footnote_separator = Some(frame); } @@ -679,12 +904,24 @@ impl<'a, 'b> Insertions<'a, 'b> { /// Subtracting this from the total region size yields the available space /// for distribution. fn height(&self) -> Abs { + self.top_size + self.bottom_size + self.footnote_size + } + + /// The combined height of the top and bottom area for floats (including + /// clearances) but excluding footnotes. + fn float_height(&self) -> Abs { self.top_size + self.bottom_size } /// Produce a frame for the full region based on the `inner` frame produced /// by distribution or column layout. - fn finalize(self, work: &mut Work, config: &Config, inner: Frame) -> Frame { + fn finalize( + self, + work: &mut Work, + config: &Config, + inner: Frame, + column_height: Option, + ) -> Frame { work.extend_skips(&self.skips); if self.top_floats.is_empty() @@ -696,11 +933,9 @@ impl<'a, 'b> Insertions<'a, 'b> { } let size = inner.size() + Size::with_y(self.height()); - let mut output = Frame::soft(size); - let mut offset_top = Abs::zero(); - let mut offset_bottom = size.y - self.bottom_size; + let mut offset_top = Abs::zero(); for (placed, frame) in self.top_floats { let x = placed.align_x.position(size.x - frame.width()); let y = offset_top; @@ -728,26 +963,29 @@ impl<'a, 'b> Insertions<'a, 'b> { // surprised and considered this strange. In LaTeX, it can be changed // with `\usepackage[bottom]{footmisc}`. We could also consider adding // configuration in the future. + + let mut float_offset_bottom = + column_height.unwrap_or(size.y - self.footnote_size) - self.bottom_size; for (placed, frame) in self.bottom_floats { - offset_bottom += placed.clearance; + float_offset_bottom += placed.clearance; let x = placed.align_x.position(size.x - frame.width()); - let y = offset_bottom; + let y = float_offset_bottom; let delta = placed.delta.zip_map(size, Rel::relative_to).to_point(); - offset_bottom += frame.height(); + float_offset_bottom += frame.height(); output.push_frame(Point::new(x, y) + delta, frame); } + let mut footnote_offset_bottom = size.y - self.footnote_size; if let Some(frame) = self.footnote_separator { - offset_bottom += config.footnote.clearance; - let y = offset_bottom; - offset_bottom += frame.height(); + footnote_offset_bottom += config.footnote.clearance; + let y = footnote_offset_bottom; + footnote_offset_bottom += frame.height(); output.push_frame(Point::with_y(y), frame); } - for frame in self.footnotes { - offset_bottom += config.footnote.gap; - let y = offset_bottom; - offset_bottom += frame.height(); + footnote_offset_bottom += config.footnote.gap; + let y = footnote_offset_bottom; + footnote_offset_bottom += frame.height(); output.push_frame(Point::with_y(y), frame); } diff --git a/crates/typst-layout/src/flow/distribute.rs b/crates/typst-layout/src/flow/distribute.rs index 3538a596e296..21d6cbc4dd16 100644 --- a/crates/typst-layout/src/flow/distribute.rs +++ b/crates/typst-layout/src/flow/distribute.rs @@ -1,32 +1,80 @@ +use ecow::EcoVec; +use typst_library::diag::{SourceDiagnostic, SourceResult}; use typst_library::introspection::Tag; use typst_library::layout::{ - Abs, Axes, FixedAlignment, Fr, Frame, FrameItem, Point, Region, Regions, Rel, Size, + Abs, Axes, FixedAlignment, Fr, Frame, FrameItem, PlacementScope, Point, Region, + Regions, Rel, Size, }; use typst_utils::Numeric; -use super::{ - Child, Composer, FlowResult, LineChild, MultiChild, MultiSpill, PlacedChild, - SingleChild, Stop, Work, -}; +use super::compose::{Composer, InsertionStop, Migration, RelayoutStop}; +use super::{Child, LineChild, MultiChild, MultiSpill, PlacedChild, SingleChild, Work}; + +/// A control flow event during distribution. +enum Stop { + /// Indicates that the current subregion should be finished. + Finish(Finish), + /// Indicates that the given scope should be relayouted. + Relayout(PlacementScope), + /// A fatal error. + Error(EcoVec), +} + +/// The reason why the current region should finish. +enum Finish { + /// A lack of space. + Soft, + /// An explicit break. + Forced, +} + +impl From> for Stop +where + R: Into, +{ + fn from(stop: InsertionStop) -> Self { + match stop { + InsertionStop::Relayout(r) => Self::Relayout(r.into()), + InsertionStop::MigrateOrigin(()) => Self::Finish(Finish::Soft), + InsertionStop::Error(error) => Self::Error(error), + } + } +} + +impl From> for Stop { + fn from(error: EcoVec) -> Self { + Self::Error(error) + } +} /// Distributes as many children as fit from `composer.work` into the first -/// region and returns the resulting frame. -pub fn distribute(composer: &mut Composer, regions: Regions) -> FlowResult { +/// region and returns the resulting frame and the height actually used +/// by the inner contents (for column balancing). +pub fn distribute( + composer: &mut Composer, + regions: Regions, + balancing_target: Option, +) -> Result<(Frame, Abs), RelayoutStop> { let mut distributor = Distributor { composer, regions, items: vec![], + used: Size::zero(), + target: balancing_target, sticky: None, stickable: None, }; let init = distributor.snapshot(); let forced = match distributor.run() { Ok(()) => distributor.composer.work.done(), - Err(Stop::Finish(forced)) => forced, - Err(err) => return Err(err), + Err(Stop::Finish(finish)) => matches!(finish, Finish::Forced), + Err(Stop::Relayout(scope)) => return Err(RelayoutStop::Relayout(scope)), + Err(Stop::Error(error)) => return Err(RelayoutStop::Error(error)), }; let region = Region::new(regions.size, regions.expand); - distributor.finalize(region, init, forced) + distributor + .finalize(region, init, forced) + .map_err(RelayoutStop::Error) } /// State for distribution. @@ -39,6 +87,10 @@ struct Distributor<'a, 'b, 'x, 'y, 'z> { regions: Regions<'z>, /// Already laid out items, not yet aligned. items: Vec>, + /// Size used by laid out items. + used: Size, + /// The target height for column balancing. + target: Option, /// A snapshot which can be restored to migrate a suffix of sticky blocks to /// the next region. sticky: Option>, @@ -68,6 +120,7 @@ struct Distributor<'a, 'b, 'x, 'y, 'z> { struct DistributionSnapshot<'a, 'b> { work: Work<'a, 'b>, items: usize, + used: Size, } /// A laid out item in a distribution. @@ -104,7 +157,7 @@ impl Item<'_, '_> { impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { /// Distributes content into the region. - fn run(&mut self) -> FlowResult<()> { + fn run(&mut self) -> Result<(), Stop> { // First, handle spill of a breakable block. if let Some(spill) = self.composer.work.spill.take() { self.multi_spill(spill)?; @@ -123,11 +176,11 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { /// Processes a single child. /// /// - Returns `Ok(())` if the child was successfully processed. - /// - Returns `Err(Stop::Finish)` if a region break should be triggered. + /// - Returns `Err(Stop::Finish(_))` if a region break should be triggered. /// - Returns `Err(Stop::Relayout(_))` if the region needs to be relayouted /// due to an insertion (float/footnote). /// - Returns `Err(Stop::Error(_))` if there was a fatal error. - fn child(&mut self, child: &'b Child<'a>) -> FlowResult<()> { + fn child(&mut self, child: &'b Child<'a>) -> Result<(), Stop> { match child { Child::Tag(tag) => self.tag(tag), Child::Rel(amount, weakness) => self.rel(*amount, *weakness), @@ -156,6 +209,12 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { } } + /// Mark the amount of height used and reduce the region height accordingly. + fn use_height(&mut self, amount: Abs) { + self.regions.size.y -= amount; + self.used.y += amount; + } + /// Processes relative spacing. fn rel(&mut self, amount: Rel, weakness: u8) { let amount = amount.relative_to(self.regions.base().y); @@ -163,7 +222,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { return; } - self.regions.size.y -= amount; + self.use_height(amount); self.items.push(Item::Abs(amount, weakness)); } @@ -192,8 +251,8 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { if weakness <= prev_weakness && (weakness < prev_weakness || amount > prev_amount) { - self.regions.size.y -= amount - prev_amount; *item = Item::Abs(amount, weakness); + self.use_height(amount - prev_amount); } return false; } @@ -244,7 +303,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { for (i, item) in self.items.iter().enumerate().rev() { match *item { Item::Abs(amount, 1..) => { - self.regions.size.y += amount; + self.use_height(-amount); self.items.remove(i); break; } @@ -270,33 +329,44 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { Abs::zero() } + /// Whether the amount fits into the remaining region, taking into account + /// column balancing limits. + pub fn fits(&self, amount: Abs) -> bool { + self.regions.size.y.fits(amount) + && self + .target + // Add elements as long as the balancing target is not reached. By not including + // the amount itself here, we avoid protruding items to cumulate in the last column. + .is_none_or(|target| target.fits(self.used.y)) + } + /// Processes a line of a paragraph. - fn line(&mut self, line: &'b LineChild) -> FlowResult<()> { + fn line(&mut self, line: &'b LineChild) -> Result<(), Stop> { // If the line doesn't fit and a followup region may improve things, // finish the region. - if !self.regions.size.y.fits(line.frame.height()) && self.regions.may_progress() { - return Err(Stop::Finish(false)); + if !self.fits(line.frame.height()) && self.regions.may_progress() { + return Err(Stop::Finish(Finish::Soft)); } // If the line's need, which includes its own height and that of // following lines grouped by widow/orphan prevention, does not fit into // the current region, but does fit into the next region, finish the // region. - if !self.regions.size.y.fits(line.need) + if !self.fits(line.need) && self .regions .iter() .nth(1) .is_some_and(|region| region.y.fits(line.need)) { - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } self.frame(line.frame.clone(), line.align, false, false) } /// Processes an unbreakable block. - fn single(&mut self, single: &'b SingleChild<'a>) -> FlowResult<()> { + fn single(&mut self, single: &'b SingleChild<'a>) -> Result<(), Stop> { // Lay out the block. let frame = single.layout( self.composer.engine, @@ -305,8 +375,13 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // Handle fractionally sized blocks. if let Some(fr) = single.fr { - self.composer - .footnotes(&self.regions, &frame, Abs::zero(), false, true)?; + self.composer.footnotes( + &self.regions, + &frame, + Abs::zero(), + false, + Migration::ALLOW, + )?; self.flush_tags(); self.items.push(Item::Fr(fr, 0, Some(single))); return Ok(()); @@ -314,23 +389,31 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // If the block doesn't fit and a followup region may improve things, // finish the region. - if !self.regions.size.y.fits(frame.height()) && self.regions.may_progress() { - return Err(Stop::Finish(false)); + if !self.fits(frame.height()) && self.regions.may_progress() { + return Err(Stop::Finish(Finish::Soft)); } self.frame(frame, single.align, single.sticky, false) } /// Processes a breakable block. - fn multi(&mut self, multi: &'b MultiChild<'a>) -> FlowResult<()> { + fn multi(&mut self, multi: &'b MultiChild<'a>) -> Result<(), Stop> { + let mut pod = self.regions; + + // For column balancing, reduce the region size for layout. + if let Some(lim) = self.target { + let remaining = lim - self.used.y; + pod.size.y.set_min(remaining); + } + // Skip directly if the region is already (over)full. `line` and // `single` implicitly do this through their `fits` checks. - if self.regions.is_full() { - return Err(Stop::Finish(false)); + if pod.is_full() { + return Err(Stop::Finish(Finish::Soft)); } // Lay out the block. - let (frame, spill) = multi.layout(self.composer.engine, self.regions)?; + let (frame, spill) = multi.layout(self.composer.engine, pod)?; if frame.is_empty() && spill.as_ref().is_some_and(|s| s.exist_non_empty_frame) && self.regions.may_progress() @@ -338,7 +421,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // If the first frame is empty, but there are non-empty frames in // the spill, the whole child should be put in the next region to // avoid any invisible orphans at the end of this region. - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } self.frame(frame, multi.align, multi.sticky, true)?; @@ -348,30 +431,38 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { if let Some(spill) = spill { self.composer.work.spill = Some(spill); self.composer.work.advance(); - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } Ok(()) } /// Processes spillover from a breakable block. - fn multi_spill(&mut self, spill: MultiSpill<'a, 'b>) -> FlowResult<()> { + fn multi_spill(&mut self, spill: MultiSpill<'a, 'b>) -> Result<(), Stop> { + let mut pod = self.regions; + + // For column balancing, reduce the region size for layout. + if let Some(lim) = self.target { + let remaining = lim - self.used.y; + pod.size.y.set_min(remaining); + } + // Skip directly if the region is already (over)full. - if self.regions.is_full() { + if pod.is_full() { self.composer.work.spill = Some(spill); - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } // Lay out the spilled remains. let align = spill.align(); - let (frame, spill) = spill.layout(self.composer.engine, self.regions)?; + let (frame, spill) = spill.layout(self.composer.engine, pod)?; self.frame(frame, align, false, true)?; // If there's still more, save it into the `spill` and finish the // region. if let Some(spill) = spill { self.composer.work.spill = Some(spill); - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } Ok(()) @@ -384,56 +475,63 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { align: Axes, sticky: bool, breakable: bool, - ) -> FlowResult<()> { - if sticky { - // If the frame is sticky and we haven't remembered a preceding - // sticky element, make a checkpoint which we can restore should we - // end on this sticky element. - // - // The first sticky block within consecutive sticky blocks - // determines whether this group of sticky blocks has stickiness - // disabled or not. - // - // The criteria used here is: if migrating this group of sticky - // blocks together with the "attached" block can't improve the lack - // of space, since we're at the start of the region, then we don't - // do so, and stickiness is disabled (at least, for this region). - // Otherwise, migration is allowed. - // - // Note that, since the whole region is checked, this ensures sticky - // blocks at the top of a block - but not necessarily of the page - - // can still be migrated. - if self.sticky.is_none() - && *self.stickable.get_or_insert_with(|| self.regions.may_progress()) - { - self.sticky = Some(self.snapshot()); - } - } else if !frame.is_empty() { - // If the frame isn't sticky, we can forget a previous snapshot. We - // interrupt a group of sticky blocks, if there was one, so we reset - // the saved stickable check for the next group of sticky blocks. - self.sticky = None; - self.stickable = None; + ) -> Result<(), Stop> { + // If the frame is sticky and we haven't remembered a preceding sticky + // element, make a checkpoint which we can restore should we end on + // this sticky element. + // + // The first sticky block within consecutive sticky blocks determines + // whether this group of sticky blocks has stickiness disabled or not. + // + // The criteria used here is: if migrating this group of sticky blocks + // together with the "attached" block can't improve the lack of space, + // since we're at the start of the region, then we don't do so, and + // stickiness is disabled (at least, for this region). Otherwise, + // migration is allowed. + // + // Note that, since the whole region is checked, this ensures sticky + // blocks at the top of a block - but not necessarily of the page - can + // still be migrated. + if sticky + && self.sticky.is_none() + && *self.stickable.get_or_insert_with(|| self.regions.may_progress()) + { + self.sticky = Some(self.snapshot()); } // Handle footnotes. + // + // This must happen before we forget a previous sticky snapshot below. + // If a non-sticky frame's footnote doesn't fit, the frame and any + // preceding sticky blocks attached to it need to migrate to the next + // region together. Resetting the sticky state first would then strand + // those sticky blocks in this region. self.composer.footnotes( &self.regions, &frame, frame.height(), breakable, - true, + Migration::ALLOW, )?; + if !sticky && !frame.is_empty() { + // If the frame isn't sticky, we can forget a previous snapshot. We + // interrupt a group of sticky blocks, if there was one, so we reset + // the saved stickable check for the next group of sticky blocks. + self.sticky = None; + self.stickable = None; + } + // Push an item for the frame. - self.regions.size.y -= frame.height(); + self.use_height(frame.height()); + self.used.x.set_max(frame.width()); self.flush_tags(); self.items.push(Item::Frame(frame, align)); Ok(()) } /// Processes an absolutely or floatingly placed child. - fn placed(&mut self, placed: &'b PlacedChild<'a>) -> FlowResult<()> { + fn placed(&mut self, placed: &'b PlacedChild<'a>) -> Result<(), Stop> { if placed.float { // If the element is floatingly placed, let the composer handle it. // It might require relayout because the area available for @@ -441,18 +539,23 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // spacing temporarily available again because it can collapse if it // ends up at a break due to the float. let weak_spacing = self.weak_spacing(); - self.regions.size.y += weak_spacing; + self.use_height(-weak_spacing); self.composer.float( placed, &self.regions, self.items.iter().any(|item| matches!(item, Item::Frame(..))), - true, + Migration::ALLOW, )?; - self.regions.size.y -= weak_spacing; + self.use_height(weak_spacing); } else { let frame = placed.layout(self.composer.engine, self.regions.base())?; - self.composer - .footnotes(&self.regions, &frame, Abs::zero(), true, true)?; + self.composer.footnotes( + &self.regions, + &frame, + Abs::zero(), + true, + Migration::ALLOW, + )?; self.flush_tags(); self.items.push(Item::Placed(frame, placed)); } @@ -460,23 +563,23 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { } /// Processes a float flush. - fn flush(&mut self) -> FlowResult<()> { + fn flush(&mut self) -> Result<(), Stop> { // If there are still pending floats, finish the region instead of // adding more content to it. if !self.composer.work.floats.is_empty() { - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } Ok(()) } /// Processes a column break. - fn break_(&mut self, weak: bool) -> FlowResult<()> { + fn break_(&mut self, weak: bool) -> Result<(), Stop> { // If there is a region to break into, break into it. if (!weak || !self.items.is_empty()) && (!self.regions.backlog.is_empty() || self.regions.last.is_some()) { self.composer.work.advance(); - return Err(Stop::Finish(true)); + return Err(Stop::Finish(Finish::Forced)); } Ok(()) } @@ -489,7 +592,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { region: Region, init: DistributionSnapshot<'a, 'b>, forced: bool, - ) -> FlowResult { + ) -> SourceResult<(Frame, Abs)> { if forced { // If this is the very end of the flow, flush pending tags. self.flush_tags(); @@ -507,31 +610,23 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { self.trim_spacing(); + let used_height_without_fr = self.used.y; + + // Determine the sum of fractionals. let mut frs = Fr::zero(); - let mut used = Size::zero(); let mut has_fr_child = false; - - // Determine the amount of used space and the sum of fractionals. for item in &self.items { - match item { - Item::Abs(v, _) => used.y += *v, - Item::Fr(v, _, child) => { - frs += *v; - has_fr_child |= child.is_some(); - } - Item::Frame(frame, _) => { - used.y += frame.height(); - used.x.set_max(frame.width()); - } - Item::Tag(_) | Item::Placed(..) => {} + if let Item::Fr(v, _, child) = item { + frs += *v; + has_fr_child |= child.is_some(); } } // When we have fractional spacing, occupy the remaining space with it. let mut fr_space = Abs::zero(); if frs.get() > 0.0 && region.size.y.is_finite() { - fr_space = region.size.y - used.y; - used.y = region.size.y; + fr_space = region.size.y - self.used.y; + self.used.y = region.size.y; } // Lay out fractionally sized blocks. @@ -542,19 +637,19 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { let length = v.share(frs, fr_space); let pod = Region::new(Size::new(region.size.x, length), region.expand); let frame = single.layout(self.composer.engine, pod)?; - used.x.set_max(frame.width()); + self.used.x.set_max(frame.width()); fr_frames.push(frame); } } // Also consider the width of insertions for alignment. if !region.expand.x { - used.x.set_max(self.composer.insertion_width()); + self.used.x.set_max(self.composer.insertion_width()); } // Determine the region's size. - let size = region.expand.select(region.size, used.min(region.size)); - let free = size.y - used.y; + let size = region.expand.select(region.size, self.used.min(region.size)); + let free = size.y - self.used.y; let mut output = Frame::soft(size); let mut ruler = FixedAlignment::Start; @@ -620,7 +715,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { } } - Ok(output) + Ok((output, used_height_without_fr)) } /// Create a snapshot of the work and items. @@ -628,6 +723,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { DistributionSnapshot { work: self.composer.work.clone(), items: self.items.len(), + used: self.used, } } @@ -635,5 +731,6 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { fn restore(&mut self, snapshot: DistributionSnapshot<'a, 'b>) { *self.composer.work = snapshot.work; self.items.truncate(snapshot.items); + self.used = snapshot.used; } } diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs index 892a982a9b8d..a36279850803 100644 --- a/crates/typst-layout/src/flow/mod.rs +++ b/crates/typst-layout/src/flow/mod.rs @@ -14,20 +14,22 @@ use bumpalo::Bump; use comemo::{Track, Tracked, TrackedMut}; use ecow::EcoVec; use rustc_hash::FxHashSet; -use typst_library::diag::{At, SourceDiagnostic, SourceResult, bail}; +use typst_library::diag::{At, SourceResult, bail}; use typst_library::engine::{Engine, Route, Sink, Traced}; use typst_library::foundations::{Content, Packed, Resolve, StyleChain}; use typst_library::introspection::{ Introspector, Location, Locator, LocatorLink, SplitLocator, Tag, }; use typst_library::layout::{ - Abs, ColumnsElem, Dir, Em, Fragment, Frame, PageElem, PlacementScope, Region, - Regions, Rel, Size, + Abs, Angle, ColumnsElem, Dir, Em, Fragment, Frame, HAlignment, PageElem, Region, + Regions, Rel, Size, VAlignment, +}; +use typst_library::model::{ + ArtifactKind, FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine, }; -use typst_library::model::{FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine}; -use typst_library::pdf::ArtifactKind; use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind}; use typst_library::text::TextElem; +use typst_library::visualize::LineElem; use typst_library::{Library, World}; use typst_utils::{LazyHash, NonZeroExt, Numeric, Protected}; @@ -35,8 +37,7 @@ use self::block::{layout_multi_block, layout_single_block}; use self::collect::{ Child, LineChild, MultiChild, MultiSpill, PlacedChild, SingleChild, collect, }; -use self::compose::{Composer, compose}; -use self::distribute::distribute; +use self::compose::compose; /// Lays out content into a single region, producing a single frame. pub fn layout_frame( @@ -71,8 +72,12 @@ pub fn layout_fragment( locator.track(), styles, regions, - NonZeroUsize::ONE, - Rel::zero(), + ColumnOptions { + count: NonZeroUsize::ONE, + balanced: false, + gutter: Rel::zero(), + separator: None, + }, ) } @@ -99,8 +104,12 @@ pub fn layout_columns( locator.track(), styles, regions, - elem.count.get(styles), - elem.gutter.resolve(styles), + ColumnOptions { + count: elem.count.get(styles), + balanced: elem.balanced.get(styles), + gutter: elem.gutter.resolve(styles), + separator: elem.separator.get_cloned(styles), + }, ) } @@ -118,8 +127,7 @@ fn layout_fragment_impl( locator: Tracked, styles: StyleChain, regions: Regions, - columns: NonZeroUsize, - column_gutter: Rel, + column: ColumnOptions, ) -> SourceResult { if !regions.size.x.is_finite() && regions.expand.x { bail!(content.span(), "cannot expand into infinite width"); @@ -159,8 +167,7 @@ fn layout_fragment_impl( &mut locator, styles, regions, - columns, - column_gutter, + column, kind.into(), ) } @@ -187,19 +194,17 @@ impl From for FlowMode { } /// Lays out realized content into regions, potentially with columns. -#[expect(clippy::too_many_arguments)] pub fn layout_flow<'a>( engine: &mut Engine, children: &[Pair<'a>], locator: &mut SplitLocator<'a>, shared: StyleChain<'a>, mut regions: Regions, - columns: NonZeroUsize, - column_gutter: Rel, + column: ColumnOptions, mode: FlowMode, ) -> SourceResult { // Prepare configuration that is shared across the whole flow. - let config = configuration(shared, regions, columns, column_gutter, mode); + let config = configuration(shared, regions, column, mode); // Collect the elements into pre-processed children. These are much easier // to handle than the raw elements. @@ -238,23 +243,34 @@ pub fn layout_flow<'a>( fn configuration<'x>( shared: StyleChain<'x>, regions: Regions, - columns: NonZeroUsize, - column_gutter: Rel, + column: ColumnOptions, mode: FlowMode, ) -> Config<'x> { Config { mode, shared, columns: { - let mut count = columns.get(); + let mut count = column.count.get(); if !regions.size.x.is_finite() { count = 1; } - let gutter = column_gutter.relative_to(regions.base().x); + let gutter = column.gutter.relative_to(regions.base().x); let width = (regions.size.x - gutter * (count - 1) as f64) / count as f64; let dir = shared.resolve(TextElem::dir); - ColumnConfig { count, width, gutter, dir } + ColumnConfig { + count, + width, + gutter, + dir, + balanced: column.balanced, + separator: column.separator.map(|separator| { + separator + .set(LineElem::length, Rel::one()) + .set(LineElem::angle, Angle::deg(90.0)) + .aligned(HAlignment::Center + VAlignment::Horizon) + }), + } }, footnote: FootnoteConfig { separator: shared @@ -352,6 +368,19 @@ impl<'a, 'b> Work<'a, 'b> { } } +/// Options defining the column layout. +#[derive(Hash)] +pub struct ColumnOptions { + /// The number of columns. + pub count: NonZeroUsize, + /// Whether column heights are to be equalized. + pub balanced: bool, + /// The spacing between columns. + pub gutter: Rel, + /// The separator between columns. + pub separator: Option, +} + /// Shared configuration for the whole flow. struct Config<'x> { /// Whether this is the root flow, which can host footnotes and line @@ -391,6 +420,10 @@ struct ColumnConfig { /// The horizontal direction in which columns progress. Defined by /// `text.dir`. dir: Dir, + /// Whether to equalize the height of columns by breaking columns early. + balanced: bool, + /// The separator between columns. + separator: Option, } /// Configuration of line numbers. @@ -410,26 +443,3 @@ struct LineNumberConfig { /// `2.5em`. default_clearance: Abs, } - -/// The result type for flow layout. -/// -/// The `Err(_)` variant incorporate control flow events for finishing and -/// relayouting regions. -type FlowResult = Result; - -/// A control flow event during flow layout. -enum Stop { - /// Indicates that the current subregion should be finished. Can be caused - /// by a lack of space (`false`) or an explicit column break (`true`). - Finish(bool), - /// Indicates that the given scope should be relayouted. - Relayout(PlacementScope), - /// A fatal error. - Error(EcoVec), -} - -impl From> for Stop { - fn from(error: EcoVec) -> Self { - Stop::Error(error) - } -} diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 68e3dcb0fe10..09215ab9a645 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1620,11 +1620,10 @@ impl<'a> GridLayouter<'a> { if self .current .lrows - .last() - .is_some_and(|row| self.grid.is_gutter_track(row.index())) + .pop_if(|row| self.grid.is_gutter_track(row.index())) + .is_some() { // Remove the last row in the region if it is a gutter row. - self.current.lrows.pop().unwrap(); self.current.repeated_header_rows = self.current.repeated_header_rows.min(self.current.lrows.len()); } diff --git a/crates/typst-layout/src/inline/collect.rs b/crates/typst-layout/src/inline/collect.rs index 529951b8d61c..cd596408a677 100644 --- a/crates/typst-layout/src/inline/collect.rs +++ b/crates/typst-layout/src/inline/collect.rs @@ -46,11 +46,6 @@ pub enum Item<'a> { } impl<'a> Item<'a> { - /// Whether this is a tag item. - pub fn is_tag(&self) -> bool { - matches!(self, Self::Tag(_)) - } - /// If this a text item, return it. pub fn text(&self) -> Option<&ShapedText<'a>> { match self { @@ -67,6 +62,17 @@ impl<'a> Item<'a> { } } + /// Whether this item can be skipped for the purposes of finding a leading + /// or trailing item. + pub fn is_skippable(&self) -> bool { + match self { + Self::Text(text) => text.glyphs.is_empty(), + Self::Tag(_) => true, + Self::Skip(_) => true, + _ => false, + } + } + /// Return the textual representation of this item: Either just itself (for /// a text item) or a replacement string (for any other item). pub fn textual(&self) -> &str { diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index a4d33a9d0780..d33112658414 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -1,16 +1,17 @@ use std::fmt::{self, Debug, Formatter}; use std::ops::{Deref, DerefMut}; +use ecow::EcoVec; use typst_library::engine::Engine; use typst_library::introspection::{SplitLocator, Tag, TagFlags}; use typst_library::layout::{Abs, Dir, Em, Fr, Frame, FrameItem, Point}; use typst_library::model::ParLineMarker; -use typst_library::text::{Lang, TextElem, families, variant}; +use typst_library::text::{FontInstance, Lang, TextElem, families, variant}; use typst_utils::Numeric; use super::*; use crate::inline::linebreak::Trim; -use crate::inline::shaping::Adjustability; +use crate::inline::shaping::{Adjustability, ShapedGlyph}; use crate::modifiers::layout_and_modify; const SHY: char = '\u{ad}'; @@ -155,26 +156,27 @@ pub fn line<'a>( // Collect the items for the line. let mut items = Items::new(); + collect_items(&mut items, engine, p, range, &trim); + // Add a hyphen at the line start, if a previous dash should be repeated. if let Some(pred) = pred && pred.dash == Some(Dash::Hard) - && let Some(base) = pred.items.trailing_text() - && should_repeat_hyphen(base.lang, full) + && let Some(pred_text) = pred.items.trailing_text() + && should_repeat_hyphen(pred_text.lang, full) + && let Some((base_idx, base)) = items.leading_text_indexed() && let Some(hyphen) = ShapedText::hyphen(engine, p.config.fallback, base, trim.shaping, false) { - items.push(Item::Text(hyphen), LogicalIndex::START_HYPHEN); + items.prepend(Item::Text(hyphen), base_idx.before()); } - collect_items(&mut items, engine, p, range, &trim); - // Add a hyphen at the line end, if we ended on a soft hyphen. if dash == Some(Dash::Soft) - && let Some(base) = items.trailing_text() + && let Some((base_idx, base)) = items.trailing_text_indexed() && let Some(hyphen) = ShapedText::hyphen(engine, p.config.fallback, base, trim.shaping, true) { - items.push(Item::Text(hyphen), LogicalIndex::END_HYPHEN); + items.push(Item::Text(hyphen), base_idx.after()); } // Ensure that there is no weak spacing at the start and end of the line. @@ -508,7 +510,7 @@ pub fn commit( && text.styles.get(TextElem::overhang) && (line.items.len() > 1 || text.glyphs.len() > 1) { - let amount = overhang(glyph.c) * glyph.x_advance.at(glyph.size); + let amount = overhang(glyph); offset -= amount; remaining += amount; } @@ -520,7 +522,7 @@ pub fn commit( && text.styles.get(TextElem::overhang) && (line.items.len() > 1 || text.glyphs.len() > 1) { - let amount = overhang(glyph.c) * glyph.x_advance.at(glyph.size); + let amount = overhang(glyph); remaining += amount; } @@ -559,7 +561,7 @@ pub fn commit( // Build the frames and determine the height and baseline. let mut frames = vec![]; - for &(idx, ref item) in line.items.indexed_iter() { + for &(idx, ref item) in line.items.iter_indexed() { let mut push = |offset: &mut Abs, frame: Frame, idx: LogicalIndex| { let width = frame.width(); top.set_max(frame.baseline()); @@ -671,26 +673,49 @@ fn add_par_line_marker( output.push(pos, FrameItem::Tag(Tag::End(loc, key, flags))); } +/// How much a glyph should hang into the end margin. +fn overhang(glyph: &ShapedGlyph) -> Abs { + // Note: Binary search is not currently worth it, but can be used if this + // grows larger. + font_overhang_table(&glyph.font) + .iter() + .find(|(id, _)| *id == glyph.glyph_id) + .map_or(Abs::zero(), |&(_, factor)| factor * glyph.x_advance.at(glyph.size)) +} + +/// Generates a sorted table with overhang ratios indexed by glyph ID for a +/// particular font. +#[comemo::memoize] +fn font_overhang_table(font: &FontInstance) -> EcoVec<(u16, f64)> { + let mut table = EcoVec::with_capacity(DEFAULT_OVERHANG_TABLE.len()); + for &(c, factor) in DEFAULT_OVERHANG_TABLE { + if let Some(id) = font.ttf().glyph_index(c) { + table.push((id.0, factor)); + } + } + table.make_mut().sort_unstable_by_key(|(id, _)| *id); + table +} + /// How much a character should hang into the end margin. /// /// For more discussion, see: /// -fn overhang(c: char) -> f64 { - match c { - // Dashes. - '–' | '—' => 0.2, - '-' | '\u{ad}' => 0.55, - - // Punctuation. - '.' | ',' => 0.8, - ':' | ';' => 0.3, - - // Arabic - '\u{60C}' | '\u{6D4}' => 0.4, - - _ => 0.0, - } -} +const DEFAULT_OVERHANG_TABLE: &[(char, f64)] = &[ + // Dashes. + ('–', 0.2), + ('—', 0.2), + ('-', 0.55), + ('\u{ad}', 0.55), + // Punctuation. + ('.', 0.8), + (',', 0.8), + (';', 0.3), + (':', 0.3), + // Arabic + ('\u{60C}', 0.4), + ('\u{6D4}', 0.4), +]; /// A collection of owned or borrowed inline items. pub struct Items<'a>(Vec<(LogicalIndex, ItemEntry<'a>)>); @@ -706,6 +731,11 @@ impl<'a> Items<'a> { self.0.push((idx, entry.into())); } + /// Insert a new item at the start of the line. + pub fn prepend(&mut self, entry: impl Into>, idx: LogicalIndex) { + self.0.insert(0, (idx, entry.into())); + } + /// Iterate over the items. pub fn iter(&self) -> impl DoubleEndedIterator> { self.0.iter().map(|(_, item)| &**item) @@ -716,30 +746,46 @@ impl<'a> Items<'a> { /// /// Note that this is different from `.iter().enumerate()` which would /// provide the indices in visual order! - pub fn indexed_iter( + pub fn iter_indexed( &self, ) -> impl DoubleEndedIterator)> { self.0.iter() } - /// Access the first item (skipping tags), if it is text. + /// Access the first unskippable item, if it is text. pub fn leading_text(&self) -> Option<&ShapedText<'a>> { - self.0.iter().find(|(_, item)| !item.is_tag())?.1.text() + self.iter().find(|item| !item.is_skippable())?.text() } - /// Access the first item (skipping tags) mutably, if it is text. + /// Access the first unskippable item alongside its index, if it is text. + pub fn leading_text_indexed(&self) -> Option<(LogicalIndex, &ShapedText<'a>)> { + let (idx, item) = self.0.iter().find(|(_, item)| !item.is_skippable())?; + let text = item.text()?; + Some((*idx, text)) + } + + /// Access the first unskippable item mutably, if it is text. pub fn leading_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { - self.0.iter_mut().find(|(_, item)| !item.is_tag())?.1.text_mut() + let (_, item) = self.0.iter_mut().find(|(_, item)| !item.is_skippable())?; + item.text_mut() } - /// Access the last item (skipping tags), if it is text. + /// Access the last unskippable item, if it is text. pub fn trailing_text(&self) -> Option<&ShapedText<'a>> { - self.0.iter().rev().find(|(_, item)| !item.is_tag())?.1.text() + self.iter().rev().find(|item| !item.is_skippable())?.text() } - /// Access the last item (skipping tags) mutably, if it is text. + /// Access the last unskippable item alongside its index, if it is text. + pub fn trailing_text_indexed(&self) -> Option<(LogicalIndex, &ShapedText<'a>)> { + let (idx, item) = self.0.iter().rev().find(|(_, item)| !item.is_skippable())?; + let text = item.text()?; + Some((*idx, text)) + } + + /// Access the last unskippable item mutably, if it is text. pub fn trailing_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { - self.0.iter_mut().rev().find(|(_, item)| !item.is_tag())?.1.text_mut() + let (_, item) = self.0.iter_mut().rev().find(|(_, item)| !item.is_skippable())?; + item.text_mut() } /// Reorder the items starting at the given index to RTL. @@ -784,14 +830,42 @@ impl Debug for Items<'_> { pub struct LogicalIndex(usize); impl LogicalIndex { - const START_HYPHEN: Self = Self(0); - const END_HYPHEN: Self = Self(usize::MAX); - /// Create a logical index from the index of an item in the [`p.items`](Preparation::items). const fn from_item_index(i: usize) -> Self { // This won't overflow because the `idx` comes from a vector which is // limited to `isize::MAX` elements. - Self(i + 1) + // Create indices in a way that there is always a space before the + // first, between two and (most likely) after the last one. This allows + // generating logical indices for soft hyphens. One space is enough + // because there is at most a single leading and trailing item + // before/after the first/last text item in the line. + Self(2 * i + 1) + } + + /// Create a new logical index directly before this one. + /// + /// Should only be called on a "real" logical index, not one that was + /// created using [`Self::before`] or [`Self::after`], because there is only + /// room for a single additional logical index between two items. + const fn before(self) -> Self { + Self( + self.0 + .checked_sub(1) + .expect("can't create logical index before this one"), + ) + } + + /// Create a new logical index directly after this one. + /// + /// Should only be called on a "real" logical index, not one that was + /// created using [`Self::before`] or [`Self::after`], because there is only + /// room for a single additional logical index between two items. + const fn after(self) -> Self { + Self( + self.0 + .checked_add(1) + .expect("can't create logical index after this one"), + ) } } diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index 5e691221861a..3902fd62061a 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -706,11 +706,23 @@ fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) { let mut last = 0; let mut iter = segmenter.segment_str(text).peekable(); + let mut next_url_scheme = find_url_scheme(text, 0); loop { // Special case for links. UAX #14 doesn't handle them well. let (head, tail) = text.split_at(last); - if head.ends_with("://") || tail.starts_with("www.") { + if next_url_scheme.as_ref().is_some_and(|next| next.end < last) { + next_url_scheme = find_url_scheme(text, last); + } + + // For URLs with domains as host, UAX #14 typically places a breakpoint + // after the `://`. However, it does not do so if a digit follows. For + // this reason, we also heuristically detect URL schemes and check + // whether we are within (or typically _at_) a scheme. + if head.ends_with("://") + || tail.starts_with("www.") + || next_url_scheme.as_ref().is_some_and(|it| it.contains(&last)) + { let (link, _) = link_prefix(tail); linebreak_link(link, |i| f(last + i, Breakpoint::Normal)); last += link.len(); @@ -780,6 +792,26 @@ fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) { } } +/// Heuristically attempts to find the next URL scheme in the given text. +/// +/// Anything matching this grammar rule that is also followed by '://', is +/// treated as a potential URL scheme. +fn find_url_scheme(full_text: &str, base: usize) -> Option { + let text = &full_text[base..]; + let end = text.find("://")?; + let start = text[..end].trim_end_matches(is_valid_in_url_scheme).len(); + Some(base + start..base + end) +} + +/// Whether the given character is valid in a URL scheme. +/// +/// This method uses the scheme grammar from +/// . +fn is_valid_in_url_scheme(c: char) -> bool { + // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + matches!(c, '+' | '-' | '.') || c.is_ascii_alphanumeric() +} + /// Generate breakpoints for hyphenations within a word. fn hyphenations( p: &Preparation, diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs index 8d639c22fb48..fc323d1d37b5 100644 --- a/crates/typst-layout/src/inline/shaping.rs +++ b/crates/typst-layout/src/inline/shaping.rs @@ -1383,11 +1383,13 @@ fn assert_glyph_ranges_in_order(glyphs: &[ShapedGlyph], dir: Dir) { } // The CJK punctuation that can appear at the beginning or end of a line. -pub const BEGIN_PUNCT_PAT: &[char] = - &['“', '‘', '《', '〈', '(', '『', '「', '【', '〖', '〔', '[', '{']; +pub const BEGIN_PUNCT_PAT: &[char] = &[ + '“', '‘', '《', '〈', '(', '『', '「', '【', '〖', '〔', '[', '{', '⦅', '〘', + '〝', +]; pub const END_PUNCT_PAT: &[char] = &[ '”', '’', ',', '.', '。', '、', ':', ';', '》', '〉', ')', '』', '」', '】', - '〗', '〕', ']', '}', '?', '!', + '〗', '〕', ']', '}', '⦆', '〙', '〟', '?', '!', ]; #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -1455,8 +1457,23 @@ fn is_cjk_left_aligned_punctuation( return true; } - // See appendix A.3 https://www.w3.org/TR/clreq/#tables_of_chinese_punctuation_marks - matches!(c, '》' | ')' | '』' | '」' | '】' | '〗' | '〕' | '〉' | ']' | '}') + // See CLReq appendix A.3 https://www.w3.org/TR/clreq/#table_of_bracket_indication_punctuation_marks + // and JLReq appendix A.2 https://www.w3.org/TR/jlreq/?lang=ja#cl-02 + matches!( + c, + '》' | ')' + | '』' + | '」' + | '】' + | '〗' + | '〕' + | '〉' + | ']' + | '}' + | '⦆' + | '〙' + | '〟' + ) } /// See @@ -1470,8 +1487,23 @@ fn is_cjk_right_aligned_punctuation( if matches!(c, '“' | '‘') && x_advance + stretchability.0 == Em::one() { return true; } - // See appendix A.3 https://www.w3.org/TR/clreq/#tables_of_chinese_punctuation_marks - matches!(c, '《' | '(' | '『' | '「' | '【' | '〖' | '〔' | '〈' | '[' | '{') + // See CLReq appendix A.3 https://www.w3.org/TR/clreq/#table_of_bracket_indication_punctuation_marks + // and JLReq appendix A.1 https://www.w3.org/TR/jlreq/?lang=ja#cl-01 + matches!( + c, + '《' | '(' + | '『' + | '「' + | '【' + | '〖' + | '〔' + | '〈' + | '[' + | '{' + | '⦅' + | '〘' + | '〝' + ) } /// See diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index 47656fad1667..f950d0d02ba3 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -7,8 +7,9 @@ use typst_library::introspection::Locator; use typst_library::layout::{ Abs, Axes, Dir, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Size, }; -use typst_library::model::{EnumElem, ListElem, Numbering, ParElem, ParbreakElem}; -use typst_library::pdf::PdfMarkerTag; +use typst_library::model::{ + EnumElem, ListElem, Numbering, ParElem, ParbreakElem, PdfMarkerTag, +}; use typst_library::text::TextElem; use typst_syntax::Span; @@ -588,8 +589,13 @@ impl<'a> ItemLayouter<'a> { let mut frames = vec![]; for (i, body_frame) in body_fragment.into_iter().enumerate() { let width = self.body_offset.x + body_frame.width(); - let height = (marker.height() + self.marker_offset.y) - .max(body_frame.height() + self.body_offset.y); + + let mut height = body_frame.height() + self.body_offset.y; + if i == self.first_frame { + // Also consider the marker height, but only for the frame into + // which it will be placed. + height.set_max(marker.height() + self.marker_offset.y); + } let mut frame = Frame::soft(Size::new(width, height)); diff --git a/crates/typst-layout/src/math/cancel.rs b/crates/typst-layout/src/math/cancel.rs index c34ead78cc1e..d80b2dc05fcb 100644 --- a/crates/typst-layout/src/math/cancel.rs +++ b/crates/typst-layout/src/math/cancel.rs @@ -42,7 +42,11 @@ pub fn layout_cancel( // The origin of our line is the very middle of the element. let center = body_size.to_point() / 2.0; - body.push_frame(center, first_line); + if item.background { + body.prepend_frame(center, first_line); + } else { + body.push_frame(center, first_line); + } if item.cross { // Draw the second line. @@ -57,7 +61,11 @@ pub fn layout_cancel( props.span, )?; - body.push_frame(center, second_line); + if item.background { + body.prepend_frame(center, second_line); + } else { + body.push_frame(center, second_line); + } } ctx.push( diff --git a/crates/typst-layout/src/math/fragment/glyph.rs b/crates/typst-layout/src/math/fragment/glyph.rs index 77d025909953..f61864d2e8f9 100644 --- a/crates/typst-layout/src/math/fragment/glyph.rs +++ b/crates/typst-layout/src/math/fragment/glyph.rs @@ -36,6 +36,7 @@ pub struct GlyphFragment { pub(super) math_size: MathSize, pub class: MathClass, pub(super) extended_shape: bool, + pub(super) stretchable_axes: Axes, // External frame stuff. modifiers: FrameModifiers, shift: Abs, @@ -160,6 +161,7 @@ impl GlyphFragment { shaped: (FontInstance, Vec), ) -> GlyphFragment { let (font, glyphs) = shaped; + let stretchable_axes = stretch_axes(&font, glyphs[0].id); let item = TextItem { text, @@ -177,6 +179,7 @@ impl GlyphFragment { // Math math_size, class, + stretchable_axes, // Math in need of updating. extended_shape: false, italics_correction: Abs::zero(), @@ -387,7 +390,7 @@ fn decide(glyph: &GlyphFragment, stretch: &Stretch) -> Action { let font = &glyph.item.font; let id = glyph.item.glyphs[0].id; - let axes = stretch_axes(font, id); + let axes = glyph.stretchable_axes; let assess = |axis| { let Some((target, short_fall)) = resolve_stretch(glyph, stretch, axis) else { diff --git a/crates/typst-layout/src/math/fragment/mod.rs b/crates/typst-layout/src/math/fragment/mod.rs index 30a7ae308ef0..a252acecd022 100644 --- a/crates/typst-layout/src/math/fragment/mod.rs +++ b/crates/typst-layout/src/math/fragment/mod.rs @@ -7,11 +7,12 @@ use std::fmt::Debug; use ttf_parser::GlyphId; use typst_library::foundations::StyleChain; use typst_library::introspection::Tag; -use typst_library::layout::{Abs, Corner, Em, Frame, FrameItem, Point, Size}; +use typst_library::layout::{Abs, Axis, Corner, Em, Frame, FrameItem, Point, Size}; use typst_library::math::MathSize; use typst_library::math::ir::MathProperties; use typst_library::text::{FontInstance, TextElem}; use typst_library::visualize::{FixedStroke, Paint}; +use typst_utils::Get; use unicode_math_class::MathClass; use self::glyph::kern_at_height; @@ -126,6 +127,13 @@ impl MathFragment { } } + pub fn is_stretchable(&self, axis: Axis) -> bool { + match self { + Self::Glyph(glyph) => glyph.stretchable_axes.get(axis), + _ => false, + } + } + pub fn is_text_like(&self) -> bool { match self { Self::Glyph(glyph) => !glyph.extended_shape, diff --git a/crates/typst-layout/src/math/scripts.rs b/crates/typst-layout/src/math/scripts.rs index 3767de281403..d865a3fecd5e 100644 --- a/crates/typst-layout/src/math/scripts.rs +++ b/crates/typst-layout/src/math/scripts.rs @@ -1,5 +1,6 @@ // Can be re-enabled once `Option::map_or_default` is stable in our MSRV. #![allow(unstable_name_collisions)] +#![allow(clippy::incompatible_msrv)] // Is unused in compiler versions where `Option::map_or_default` is stable. #[allow(unused_imports)] @@ -46,6 +47,7 @@ pub fn layout_scripts( item.base.set_stretch_relative_to(relative_to_width, Axis::X); let base = ctx.layout_into_fragment(&item.base, styles)?; + let stretchy = base.is_stretchable(Axis::X); let fragments = [ layout!(top_left), @@ -56,7 +58,14 @@ pub fn layout_scripts( layout!(bottom_right), ]; - layout_attachments(ctx, props, item.base.styles().unwrap_or(styles), base, fragments) + layout_attachments( + ctx, + props, + item.base.styles().unwrap_or(styles), + base, + stretchy, + fragments, + ) } /// Lays out a [`PrimesItem`]. @@ -94,6 +103,7 @@ fn layout_attachments( props: &MathProperties, styles: StyleChain, base: MathFragment, + stretchy: bool, [tl, t, tr, bl, b, br]: [Option; 6], ) -> SourceResult<()> { let (font, size) = base.font(ctx, styles); @@ -110,7 +120,7 @@ fn layout_attachments( // Calculate the distance from the base's baseline to the top attachment's // and bottom attachment's baseline. let (t_shift, b_shift) = - compute_limit_shifts(&font, size, &base, [t.as_ref(), b.as_ref()]); + compute_limit_shifts(&font, size, &base, stretchy, [t.as_ref(), b.as_ref()]); // Calculate the final frame height. let ascent = base @@ -291,6 +301,7 @@ fn compute_limit_shifts( font: &FontInstance, font_size: Abs, base: &MathFragment, + stretchy: bool, [t, b]: [Option<&MathFragment>; 2], ) -> (Abs, Abs) { // `upper_gap_min` and `lower_gap_min` give gaps to the descender and @@ -298,15 +309,31 @@ fn compute_limit_shifts( // `lower_drop_min` give gaps to each limit's baseline (see the // MathConstants table in the OpenType MATH spec). let t_shift = t.map_or_default(|t| { - let upper_gap_min = font.math().upper_limit_gap_min.at(font_size); - let upper_rise_min = font.math().upper_limit_baseline_rise_min.at(font_size); - base.ascent() + upper_rise_min.max(upper_gap_min + t.descent()) + if stretchy { + let top_shift_up = font.math().stretch_stack_top_shift_up.at(font_size); + let gap_above_min = font.math().stretch_stack_gap_above_min.at(font_size); + let gap = (top_shift_up - base.ascent() - t.descent()).max(gap_above_min); + base.ascent() + gap + t.descent() + } else { + let upper_gap_min = font.math().upper_limit_gap_min.at(font_size); + let upper_rise_min = font.math().upper_limit_baseline_rise_min.at(font_size); + base.ascent() + upper_rise_min.max(upper_gap_min + t.descent()) + } }); let b_shift = b.map_or_default(|b| { - let lower_gap_min = font.math().lower_limit_gap_min.at(font_size); - let lower_drop_min = font.math().lower_limit_baseline_drop_min.at(font_size); - base.descent() + lower_drop_min.max(lower_gap_min + b.ascent()) + if stretchy { + let bottom_shift_down = + font.math().stretch_stack_bottom_shift_down.at(font_size); + let gap_below_min = font.math().stretch_stack_gap_below_min.at(font_size); + let gap = + (bottom_shift_down - base.descent() - b.ascent()).max(gap_below_min); + base.descent() + gap + b.ascent() + } else { + let lower_gap_min = font.math().lower_limit_gap_min.at(font_size); + let lower_drop_min = font.math().lower_limit_baseline_drop_min.at(font_size); + base.descent() + lower_drop_min.max(lower_gap_min + b.ascent()) + } }); (t_shift, b_shift) diff --git a/crates/typst-layout/src/pages/mod.rs b/crates/typst-layout/src/pages/mod.rs index e67ce569f7ea..9bd2ecba453a 100644 --- a/crates/typst-layout/src/pages/mod.rs +++ b/crates/typst-layout/src/pages/mod.rs @@ -8,6 +8,7 @@ use comemo::{Track, Tracked, TrackedMut}; use ecow::EcoVec; use typst_library::diag::SourceResult; use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{Content, StyleChain}; use typst_library::introspection::{ Introspector, Locator, LocatorLink, ManualPageCounter, SplitLocator, TagElem, @@ -16,6 +17,7 @@ use typst_library::layout::{FrameItem, Point}; use typst_library::model::DocumentInfo; use typst_library::routines::{Arenas, Pair, RealizationKind}; use typst_library::{Library, World}; +use typst_syntax::Spanned; use typst_utils::{LazyHash, Protected}; use self::collect::{Item, collect}; @@ -156,9 +158,11 @@ fn layout_document_common( let mut info = DocumentInfo::default(); info.populate(styles); info.populate_locale(styles); + let mut options = DocumentFormatOptions::new(&library.formats); + options.populate(Spanned::detached(styles)); let mut children = (engine.library.routines.realize)( - RealizationKind::Document { info: &mut info }, + RealizationKind::Document { info: &mut info, options: &mut options }, &mut engine, &mut locator, &arenas, @@ -168,7 +172,7 @@ fn layout_document_common( let pages = layout_pages(&mut engine, &mut children, &mut locator, styles)?; - Ok(PagedDocument::new(pages, info)) + Ok(PagedDocument::new(pages, info, options)) } /// Layouts the document's pages. diff --git a/crates/typst-layout/src/pages/run.rs b/crates/typst-layout/src/pages/run.rs index ec85fdd86ea8..c8a0045a4b1b 100644 --- a/crates/typst-layout/src/pages/run.rs +++ b/crates/typst-layout/src/pages/run.rs @@ -12,15 +12,14 @@ use typst_library::layout::{ Length, OuterVAlignment, PageElem, Paper, Region, Regions, Rel, Sides, Size, VAlignment, }; -use typst_library::model::Numbering; -use typst_library::pdf::ArtifactKind; +use typst_library::model::{ArtifactKind, Numbering}; use typst_library::routines::Pair; use typst_library::text::{LocalName, TextElem}; use typst_library::visualize::Paint; use typst_library::{Feature, Library, World}; use typst_utils::{LazyHash, Numeric, Protected}; -use crate::flow::{FlowMode, layout_flow}; +use crate::flow::{ColumnOptions, FlowMode, layout_flow}; /// A mostly finished layout for one page. Needs only knowledge of its exact /// page number to be finalized into a `Page`. (Because the margins can depend @@ -193,8 +192,12 @@ fn layout_page_run_impl( &mut locator, styles, Regions::repeat(area, area.map(Abs::is_finite)), - styles.get(PageElem::columns), - styles.get(ColumnsElem::gutter).resolve(styles), + ColumnOptions { + count: styles.get(PageElem::columns), + balanced: styles.get(ColumnsElem::balanced), + gutter: styles.get(ColumnsElem::gutter).resolve(styles), + separator: styles.get_cloned(ColumnsElem::separator), + }, FlowMode::Root, )?; diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index ad4f34637bd7..0b2907ef1b88 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -15,6 +15,7 @@ use typst_library::layout::{ Size, Sizing, SkewElem, Spacing, StackChild, StackElem, TrackSizings, VElem, }; use typst_library::math::EquationElem; +use typst_library::model::{ArtifactElem, ArtifactKind, PdfMarkerTag}; use typst_library::model::{ Attribution, BibliographyElem, CiteElem, CiteGroup, CslIndentElem, CslLightElem, Destination, DirectLinkElem, DividerElem, EmphElem, EnumElem, FigureCaption, @@ -22,7 +23,6 @@ use typst_library::model::{ OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem, TitleElem, Works, }; -use typst_library::pdf::{ArtifactElem, ArtifactKind, AttachElem, PdfMarkerTag}; use typst_library::text::{ DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, LocalName, OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, @@ -106,7 +106,6 @@ pub fn register(rules: &mut NativeRuleMap) { rules.register(Paged, EQUATION_RULE); // PDF. - rules.register(Paged, ATTACH_RULE); rules.register(Paged, ARTIFACT_RULE); rules.register(Paged, PDF_MARKER_TAG_RULE); } @@ -265,12 +264,21 @@ const HEADING_RULE: ShowFn = |elem, engine, styles| { if hanging_indent.is_auto() && align.x == FixedAlignment::Start { let pod = Region::new(Axes::splat(Abs::inf()), Axes::splat(false)); + // Add weak spacing (that will collapse) on both sides to disable CJ + // punctuation adjustment during measurement. In the actual heading + // the numbering will not be at the boundary (negative spacing + // before and the body after), so there could be misalignment + // otherwise. If there is ever a cleaner way to disable punctuation + // adjustment, that would be preferrable. + let collapsing = HElem::new(Abs::pt(1.0).into()).with_weak(true).pack(); + let measurable = collapsing.clone() + numbering.clone() + collapsing; + // We don't have a locator for the numbering here, so we just // use the measurement infrastructure for now. let link = LocatorLink::measure(location, span); let size = (engine.library.routines.layout_frame)( engine, - &numbering, + &measurable, Locator::link(&link), styles, pod, @@ -280,6 +288,7 @@ const HEADING_RULE: ShowFn = |elem, engine, styles| { indent = size.x + SPACING_TO_NUMBERING.resolve(styles); } + // The spacing is weak to eat up a potential leading space in the body. let spacing = HElem::new(SPACING_TO_NUMBERING.into()).with_weak(true).pack(); realized = numbering + spacing + realized; @@ -811,8 +820,6 @@ const EQUATION_RULE: ShowFn = |elem, _, styles| { } }; -const ATTACH_RULE: ShowFn = |_, _, _| Ok(Content::empty()); - const ARTIFACT_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); const PDF_MARKER_TAG_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index 1bee141ecb1d..33140a925ab5 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -33,6 +33,7 @@ fontdb = { workspace = true } glidesort = { workspace = true } hayagriva = { workspace = true } hayro-syntax = { workspace = true } +icu_collator = { workspace = true } icu_properties = { workspace = true } image = { workspace = true } indexmap = { workspace = true } diff --git a/crates/typst-library/src/diag.rs b/crates/typst-library/src/diag.rs index 197a57f72732..3e035bee240b 100644 --- a/crates/typst-library/src/diag.rs +++ b/crates/typst-library/src/diag.rs @@ -22,7 +22,6 @@ use typst_syntax::{ }; use utf8_iter::ErrorReportingUtf8Chars; -use crate::engine::Engine; use crate::loading::{LoadSource, Loaded}; use crate::{World, WorldExt}; @@ -160,6 +159,8 @@ macro_rules! __error { /// hint[hint_span]: "hints can have custom spans and {}", "formatting"; /// ); /// ``` +/// +/// [`Engine`]: crate::engine::Engine #[macro_export] #[doc(hidden)] #[clippy::format_args] @@ -242,10 +243,7 @@ where } }) .collect(); - if !errors.is_empty() { - return Err(errors); - } - Ok(collected) + if errors.is_empty() { Ok(collected) } else { Err(errors) } } } @@ -278,6 +276,77 @@ where } } +/// Collects an iterator of [`Warned>`]s into a result containg +/// a collection or the accumulated warnings and errors. +/// +/// Unlike normal `FromIterator` for `Result`, this will combine all the errors. +/// This is possible because a [`SourceResult`] can hold multiple errors. +pub trait CollectCombinedWarnedResult { + type Item; + + fn collect_combined_warned_result(self) -> Warned> + where + B: FromIterator; +} + +impl CollectCombinedWarnedResult for I +where + I: Iterator>>, +{ + type Item = T; + + fn collect_combined_warned_result(self) -> Warned> + where + B: FromIterator, + { + let mut warnings = EcoVec::new(); + let mut errors = EcoVec::new(); + let collected = self + .filter_map(|result| { + warnings.extend(result.warnings); + match result.output { + Ok(item) => Some(item), + Err(errs) => { + errors.extend(errs); + None + } + } + }) + .collect(); + let output = if errors.is_empty() { Ok(collected) } else { Err(errors) }; + Warned { output, warnings } + } +} + +/// A variation of [`CollectCombinedWarnedResult`] for parallel rayon iterators. +/// +/// Needs to be a separate trait because we can't have two blanket impls. +pub trait ParallelCollectCombinedWarnedResult { + type Item; + + fn collect_combined_warned_result(self) -> Warned> + where + B: FromIterator; +} + +impl ParallelCollectCombinedWarnedResult for I +where + I: rayon::iter::ParallelIterator>>, + T: Send, +{ + type Item = T; + + fn collect_combined_warned_result(self) -> Warned> + where + B: FromIterator, + { + // A more efficient approach might be possible, but this is simpler and + // pragmatic. The point of this trait is primarily to make the call-site + // convenient, not to maximize efficiency. + self.collect::>().into_iter().collect_combined_warned_result() + } +} + /// An output alongside warnings generated while producing it. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Warned { @@ -287,10 +356,50 @@ pub struct Warned { pub warnings: EcoVec, } -impl Warned { +impl Warned> { /// Maps the output, keeping the same warnings. - pub fn map R>(self, f: F) -> Warned { - Warned { output: f(self.output), warnings: self.warnings } + pub fn map R>(self, f: F) -> Warned> { + Warned { + output: self.output.map(f), + warnings: self.warnings, + } + } + + /// Maps the error, if the inner `Result` is `Err`, keeping the same + /// warnings. + pub fn map_err(self, f: F) -> Warned> + where + F: FnOnce(E) -> R, + { + Warned { + output: self.output.map_err(f), + warnings: self.warnings, + } + } + + /// Maps the output, if the inner `Result` is `Ok`. + /// + /// If `f` returns a `Warned`, the additional warnings are added. If it just + /// returns a bare result, the same warnings as before are kept. + pub fn and_then(mut self, f: F) -> Warned> + where + F: FnOnce(T) -> W, + W: Into>>, + { + Warned { + output: self.output.and_then(|output| { + let Warned { output, warnings } = f(output).into(); + self.warnings.extend(warnings); + output + }), + warnings: self.warnings, + } + } +} + +impl From for Warned { + fn from(output: T) -> Self { + Warned { output, warnings: EcoVec::new() } } } @@ -414,17 +523,14 @@ pub trait WarningSink { fn emit(&mut self, message: HintedString); } -impl WarningSink for () { - fn emit(&mut self, _: HintedString) {} +impl WarningSink for &mut T { + fn emit(&mut self, message: HintedString) { + T::emit(self, message); + } } -impl WarningSink for (&mut Engine<'_>, Span) { - fn emit(&mut self, hinted: HintedString) { - self.0.sink.warn( - SourceDiagnostic::warning(self.1, hinted.message()) - .with_hints(hinted.hints().iter().cloned()), - ); - } +impl WarningSink for () { + fn emit(&mut self, _: HintedString) {} } /// A part of a diagnostic's [trace](SourceDiagnostic::trace). diff --git a/crates/typst-library/src/engine.rs b/crates/typst-library/src/engine.rs index 17d57d5e1621..f5fa9a89ed46 100644 --- a/crates/typst-library/src/engine.rs +++ b/crates/typst-library/src/engine.rs @@ -10,7 +10,7 @@ use typst_syntax::{FileId, Span}; use typst_utils::{LazyHash, Protected}; use crate::diag::{HintedStrResult, SourceDiagnostic, SourceResult, StrResult, bail}; -use crate::foundations::{Styles, Value}; +use crate::foundations::{NormalBindingGuard, Styles, Value}; use crate::introspection::{Introspect, Introspection, Introspector}; use crate::{Library, World}; @@ -35,7 +35,7 @@ pub struct Engine<'a> { pub route: Route<'a>, } -impl Engine<'_> { +impl<'a> Engine<'a> { /// Handles a result without immediately terminating execution. Instead, it /// produces a delayed error that is only promoted to a fatal one if it /// remains by the end of the introspection loop. @@ -115,6 +115,11 @@ impl Engine<'_> { self.sink.introspection(Introspection::new(introspection)); output } + + /// Create a struct that implements [`crate::foundations::BindingGuard`]. + pub fn binding_guard(&'_ mut self, span: Span) -> NormalBindingGuard<'_, 'a> { + NormalBindingGuard { engine: self, span } + } } /// May hold a span that is currently under inspection. diff --git a/crates/typst-library/src/format.rs b/crates/typst-library/src/format.rs new file mode 100644 index 000000000000..50bbee98cd62 --- /dev/null +++ b/crates/typst-library/src/format.rs @@ -0,0 +1,429 @@ +//! Specify configurable export formats. +//! +//! All export formats have to be registered in [`Library::formats`], by passing +//! them to the `typst` crate's +//! `LibraryExt::new()` or `LibraryExt::builder()` methods. +//! +//! Each [`Format`] has an associated [`FormatElement`] with a [`FormatOptions`] +//! type. The format element will be defined on the `std.format` binding, making +//! it available to set rules. +//! +//! The options are stored in the compiled document's [`DocumentFormatOptions`] +//! and can be accessed using the [`Document::options()`] trait method. The +//! concrete type can be retrieved using [`DocumentFormatOptions::get()`] +//! providing the format element as a generic parameter. +//! +//! # Example +//! ``` +//! use typst_library::Library; +//! use typst_library::diag::{SourceResult, bail}; +//! use typst_library::engine::Engine; +//! use typst_library::format::{Complete, Fields, Format, FormatElement, Populate}; +//! use typst_library::foundations::{Args, Construct, Content, StyleChain, elem}; +//! use typst_library::model::Document; +//! use typst_syntax::Spanned; +//! +//! // This can be passed to `Library::new` as `Format::new::()` to +//! // register the format. +//! #[elem(Construct)] +//! pub struct Epub { +//! #[default] +//! pub pretty: bool +//! } +//! +//! impl FormatElement for Epub { +//! type Options = EpubFormatOptions; +//! } +//! +//! impl Construct for Epub { +//! fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { +//! bail!(args.span, "cannot be constructed manually") +//! } +//! } +//! +//! #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +//! pub struct EpubFormatOptions { +//! pub pretty: T::Value, +//! } +//! +//! impl Populate for EpubFormatOptions { +//! fn populate(&mut self, styles: Spanned) { +//! // The `SpannedValue::populate` call automatically stores the span +//! // of the specific set rule. +//! self.pretty.populate(styles); +//! } +//! } +//! +//! +//! // In the export crate. +//! fn export_epub(doc: impl Document) { +//! let options = doc.options().get::(); +//! // Export an EPUB here... +//! } +//! ``` +//! +//! # Internals +//! The [`FormatOptions`] as top-level set-rules have to be special-cased and +//! are handled in a similar way as the [`DocumentInfo`], through the +//! [`RealizationKind::Document`]. +//! +//! [`Library::formats`]: crate::Library::formats +//! [`Document::options()`]: crate::model::Document::options() +//! [`DocumentInfo`]: crate::model::DocumentInfo +//! [`RealizationKind::Document`]: crate::routines::RealizationKind::Document + +use std::any::{Any, TypeId}; +use std::fmt::Debug; +use std::hash::Hash; +use std::marker::PhantomData; + +use ecow::EcoVec; +use typst_syntax::{Span, Spanned}; + +use crate::Feature; +use crate::foundations::{ + Element, Field, Module, NativeElement, NativeRuleMap, Scope, SettableProperty, + StyleChain, +}; + +/// The global `format` module on which all registered formats will be defined. +pub fn module(formats: &[Format]) -> Module { + let mut format = Scope::deduplicating(); + format.start_category(crate::Category::Format); + + for f in formats { + let binding = format.define(f.elem.name(), f.elem); + if let Some(feature) = f.feature { + binding.with_feature(feature); + } + } + + Module::new("format", format) +} + +/// An export format with an associated [`FormatElement`]. +/// +/// See the [module level](self) docs for more information. +#[derive(Debug, Clone, Hash)] +pub struct Format { + pub elem: Element, + options: fn() -> FormatOptions, + feature: Option, + rules: Option, +} + +impl Format { + /// Create a new format with an associated [`FormatElement`]. + pub const fn new() -> Self { + Self { + elem: E::ELEM, + options: || E::Options::default().into(), + feature: None, + rules: None, + } + } + + /// Gate the format behind a feature flag. + pub const fn with_feature(mut self, feature: Feature) -> Self { + self.feature = Some(feature); + self + } + + /// Add format specific rules that will be registered. + pub const fn with_rules(mut self, register: fn(&mut NativeRuleMap)) -> Self { + self.rules = Some(register); + self + } + + /// Get the default format options of this format. + pub fn default_options(&self) -> FormatOptions { + (self.options)() + } + + /// Register the rules specific to this format. + pub fn register_rules(&self, rules: &mut NativeRuleMap) { + if let Some(register) = self.rules { + register(rules); + } + } +} + +/// An export format element with associated options that can be configured +/// using set rules. +/// +/// See the [module level](self) docs for more information. +pub trait FormatElement: NativeElement { + type Options: Populate + Default + Clone + Hash; +} + +/// A type that can be populated from a [`StyleChain`]. +/// +/// This is used inside [`FormatOptions`]. +pub trait Populate: Debug + Any + Send + Sync + 'static { + /// Populate this type with details from the given local styles. + fn populate(&mut self, styles: Spanned); +} + +impl Populate for () { + fn populate(&mut self, _: Spanned) {} +} + +trait Bounds: Populate { + fn dyn_clone(&self) -> Box; + fn dyn_hash(&self, state: &mut dyn std::hash::Hasher); +} + +impl Bounds for T +where + T: Populate + Clone + Hash, +{ + fn dyn_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn dyn_hash(&self, mut state: &mut dyn std::hash::Hasher) { + // Also hash the TypeId since values with different types but + // equal data should be different. + TypeId::of::().hash(&mut state); + self.hash(&mut state); + } +} + +/// A list of format options that have been configured by the document. +/// +/// See the [module level](self) docs for more information. +#[derive(Debug, Clone, Hash)] +pub struct DocumentFormatOptions(EcoVec); + +impl DocumentFormatOptions { + /// Initialize default format options from a list of formats. + pub fn new(formats: &[Format]) -> Self { + Self(formats.iter().map(Format::default_options).collect()) + } + + /// Get a concrete format option type. + /// + /// For example with `typst_pdf`, the `PdfFormatOptions` can be retrieved + /// like this on a compiled document. + /// ```rust,ignore + /// let pdf_options = document.options().get::(); + /// ``` + pub fn get(&self) -> &T::Options { + self.0 + .iter() + .find_map(FormatOptions::downcast::) + .unwrap_or_else(|| { + let format = std::any::type_name::(); + let options = std::any::type_name::(); + panic!( + "format `{format}` with options `{options}` not found\n\ + hint: if you're a developer, you need to register `Library::formats`" + ); + }) + } + + /// Populate the format options with details from the given styles. + pub fn populate(&mut self, styles: Spanned) { + for o in self.0.make_mut() { + o.populate(styles); + } + } +} + +/// Format options that have been configured by the document. +/// +/// See the [module level](self) docs for more information. +pub struct FormatOptions(Box); + +impl FormatOptions { + /// Populate these options from the spanned styles. + pub fn populate(&mut self, styles: Spanned) { + self.0.populate(styles); + } + + /// Attempt to downcast this option to a concrete format option type. + pub fn downcast(&self) -> Option<&T::Options> { + let inner: &dyn Bounds = &*self.0; + (inner as &dyn Any).downcast_ref() + } +} + +impl From for FormatOptions +where + T: Populate + Clone + Hash + Debug + Send + Sync + 'static, +{ + fn from(value: T) -> Self { + Self(Box::new(value)) + } +} + +impl Clone for FormatOptions { + fn clone(&self) -> Self { + Self(self.0.dyn_clone()) + } +} + +impl Hash for FormatOptions { + fn hash(&self, state: &mut H) { + self.0.dyn_hash(state); + } +} + +impl Debug for FormatOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// A trait that allows specifying [`Complete`] and [`Partial`] types. +/// +/// Types can take a generic marker and wrap their fields in the +/// [`Fields::Value`] type, which will either wrap them in an `Option` for the +/// [`Partial`] tag or required for the [`Complete`] tag. +/// +/// See the [module level](self) docs for more information. +pub trait Fields: Default { + type Value: Debug + Default + Clone + Eq + Hash + where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash; +} + +/// Marker for types with fully resolved fields. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Complete; + +impl Fields for Complete { + type Value + = SpannedValue + where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash; +} + +/// A [`Spanned`] value that is associtated with a specific [`Field`] of an +/// element. This allows reusing the [`Default`] value specified on the element, +/// by reading from an empty [`StyleChain`]. +#[derive(Debug, Clone, Hash)] +pub struct SpannedValue +where + E: SettableProperty, +{ + /// The format element's field this value should be read from. + field: PhantomData>, + pub v: E::Type, + pub span: Span, +} + +impl SpannedValue +where + E: SettableProperty, +{ + /// Create a new spanned value. + pub fn new(v: E::Type, span: Span) -> Self { + Self { span, v, field: PhantomData } + } + + /// Create a new spanned value with a detached span. + pub fn detached(v: E::Type) -> Self { + Self::new(v, Span::detached()) + } + + /// Populate this value from the given local styles. + /// If the field has been set, this will also update the span. + pub fn populate(&mut self, styles: Spanned) { + if styles.v.has(Field::::new()) { + *self = + SpannedValue::new(styles.v.get_cloned(Field::::new()), styles.span); + } + } +} + +impl Default for SpannedValue +where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash, +{ + fn default() -> Self { + Self::new( + StyleChain::default().get_cloned(Field::::new()), + Span::detached(), + ) + } +} + +impl Copy for SpannedValue +where + E: SettableProperty, + E::Type: Copy, +{ +} + +impl Eq for SpannedValue +where + E: SettableProperty, + E::Type: Eq, +{ +} + +impl PartialEq for SpannedValue +where + E: SettableProperty, + E::Type: PartialEq, +{ + fn eq(&self, other: &Self) -> bool { + self.v == other.v && self.span == other.span + } +} + +impl From> for SpannedValue +where + E: SettableProperty, +{ + fn from(value: Spanned) -> Self { + Self::new(value.v, value.span) + } +} + +/// Marker for types with optional/partial fields. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Partial; + +impl Fields for Partial { + type Value + = Option + where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash; +} + +impl Partial { + /// If present, returns the partial value with a detached span, otherwise + /// the default value is returned. + pub fn resolve( + partial: ::Value, + default: ::Value, + ) -> ::Value + where + E: SettableProperty, + E::Type: Debug + Copy + Clone + Eq + Hash, + { + partial.map(SpannedValue::detached).unwrap_or(default) + } + + /// If present, returns the partial value with a detached span, otherwise + /// the default value is returned. + pub fn resolve_cloned( + partial: &::Value, + default: &::Value, + ) -> ::Value + where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash, + { + partial + .clone() + .map(SpannedValue::detached) + .unwrap_or_else(|| default.clone()) + } +} diff --git a/crates/typst-library/src/foundations/args.rs b/crates/typst-library/src/foundations/args.rs index e0f26cb0a64a..97a13df54022 100644 --- a/crates/typst-library/src/foundations/args.rs +++ b/crates/typst-library/src/foundations/args.rs @@ -50,7 +50,7 @@ use crate::foundations::{ /// #let dict = (fill: blue) /// #text(..dict)[Hello] /// ``` -#[ty(scope, cast, name = "arguments")] +#[ty(scope, cast, name = "arguments", since = "forever")] #[derive(Clone, Hash)] pub struct Args { /// The callsite span for the function. This is not the span of the argument @@ -160,8 +160,9 @@ impl Args { /// The error message for missing arguments. fn missing_argument(&self, what: &str) -> SourceDiagnostic { for item in &self.items { - let Some(name) = item.name.as_deref() else { continue }; - if name == what { + if let Some(name) = item.name.as_deref() + && name == what + { return error!( item.span, "the argument `{what}` is positional"; @@ -327,7 +328,7 @@ impl Args { /// #let args = arguments(stroke: red, inset: 1em, [Body]) /// #box(..args) /// ``` - #[func(constructor)] + #[func(constructor, since = "0.10.0")] pub fn construct( args: &mut Args, /// The arguments to construct. @@ -339,7 +340,7 @@ impl Args { } /// The number of arguments, positional or named. - #[func(title = "Length")] + #[func(title = "Length", since = "0.15.0")] pub fn len(&self) -> usize { self.items.len() } @@ -355,7 +356,7 @@ impl Args { /// Named arguments can also be accessed with field syntax (e.g. /// `{arguments(key: 42).key}`) if no default is needed. Unlike /// @dictionary[dictionaries], fields on arguments cannot be modified. - #[func] + #[func(since = "0.12.0")] pub fn at( &self, /// The index or name of the argument to get. @@ -371,7 +372,7 @@ impl Args { } /// Returns the captured positional arguments as an array. - #[func(name = "pos", title = "Positional")] + #[func(name = "pos", title = "Positional", since = "forever")] pub fn to_pos(&self) -> Array { self.items .iter() @@ -381,7 +382,7 @@ impl Args { } /// Returns the captured named arguments as a dictionary. - #[func(name = "named")] + #[func(name = "named", since = "forever")] pub fn to_named(&self) -> Dict { self.items .iter() @@ -398,7 +399,7 @@ impl Args { /// .filter(v => v > 0) /// } /// ``` - #[func] + #[func(since = "0.15.0")] pub fn filter( self, engine: &mut Engine, @@ -427,7 +428,7 @@ impl Args { /// .map(v => v + 1) /// } /// ``` - #[func] + #[func(since = "0.15.0")] pub fn map( self, engine: &mut Engine, diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs index 58b811bdcb04..5464042c5bb6 100644 --- a/crates/typst-library/src/foundations/array.rs +++ b/crates/typst-library/src/foundations/array.rs @@ -69,7 +69,7 @@ pub use crate::__array as array; /// #(("A", "B", "C") /// .join(", ", last: " and ")) /// ``` -#[ty(scope, cast)] +#[ty(scope, cast, since = "forever")] #[derive(Default, Clone, PartialEq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct Array(EcoVec); @@ -160,7 +160,7 @@ impl Array { /// #let hi = "Hello 😃" /// #array(bytes(hi)) /// ``` - #[func(constructor)] + #[func(constructor, since = "0.7.0")] pub fn construct( /// The value that should be converted to an array. value: ToArray, @@ -169,15 +169,15 @@ impl Array { } /// The number of values in the array. - #[func(title = "Length")] + #[func(title = "Length", since = "forever")] pub fn len(&self) -> usize { self.0.len() } - /// Returns the first item in the array. May be used on the left-hand side + /// Returns the first item in the array. May be used on the left-hand side of /// an assignment. Returns the default value if the array is empty or fails - /// with an error is no default value was specified. - #[func] + /// with an error if no default value was specified. + #[func(since = "forever")] pub fn first( &self, /// A default value to return if the array is empty. @@ -189,8 +189,8 @@ impl Array { /// Returns the last item in the array. May be used on the left-hand side of /// an assignment. Returns the default value if the array is empty or fails - /// with an error is no default value was specified. - #[func] + /// with an error if no default value was specified. + #[func(since = "forever")] pub fn last( &self, /// A default value to return if the array is empty. @@ -204,7 +204,7 @@ impl Array { /// left-hand side of an assignment. Returns the default value if the index /// is out of bounds or fails with an error if no default value was /// specified. - #[func] + #[func(since = "forever")] pub fn at( &self, /// The index at which to retrieve the item. If negative, indexes from @@ -221,7 +221,7 @@ impl Array { } /// Adds a value to the end of the array. - #[func] + #[func(since = "forever")] pub fn push( &mut self, /// The value to insert at the end of the array. @@ -232,7 +232,7 @@ impl Array { /// Removes the last item from the array and returns it. Fails with an error /// if the array is empty. - #[func] + #[func(since = "forever")] pub fn pop(&mut self) -> StrResult { self.0.pop().ok_or_else(array_is_empty) } @@ -242,7 +242,7 @@ impl Array { /// out of bounds. /// /// To replace an element of an array, use @array.at[`at`]. - #[func] + #[func(since = "forever")] pub fn insert( &mut self, /// The index at which to insert the item. If negative, indexes from the @@ -257,7 +257,7 @@ impl Array { } /// Removes the value at the specified index from the array and return it. - #[func] + #[func(since = "forever")] pub fn remove( &mut self, /// The index at which to remove the item. If negative, indexes from the @@ -275,7 +275,7 @@ impl Array { /// Extracts a subslice of the array. Fails with an error if the start or /// end index is out of bounds. - #[func] + #[func(since = "forever")] pub fn slice( &self, /// The start index (inclusive). If negative, indexes from the back. @@ -303,7 +303,7 @@ impl Array { /// /// This method also has dedicated syntax: You can write `{2 in (1, 2, 3)}` /// instead of `{(1, 2, 3).contains(2)}`. - #[func] + #[func(since = "forever")] pub fn contains( &self, /// The value to search for. @@ -314,7 +314,7 @@ impl Array { /// Searches for an item for which the given function returns `{true}` and /// returns the first match or `{none}` if there is no match. - #[func] + #[func(since = "forever")] pub fn find( &self, engine: &mut Engine, @@ -336,7 +336,7 @@ impl Array { /// Searches for an item for which the given function returns `{true}` and /// returns the index of the first match or `{none}` if there is no match. - #[func] + #[func(since = "forever")] pub fn position( &self, engine: &mut Engine, @@ -380,7 +380,7 @@ impl Array { /// #range(21, step: 4) \ /// #range(5, 2, step: -1) /// ``` - #[func] + #[func(since = "forever")] pub fn range( args: &mut Args, /// The start of the range (inclusive). @@ -444,7 +444,7 @@ impl Array { /// Produces a new array with only the items from the original one for which /// the given function returns `{true}`. - #[func] + #[func(since = "forever")] pub fn filter( &self, engine: &mut Engine, @@ -467,7 +467,7 @@ impl Array { /// Produces a new array in which all items from the original one were /// transformed with the given function. - #[func] + #[func(since = "forever")] pub fn map( self, engine: &mut Engine, @@ -494,7 +494,7 @@ impl Array { /// /// #("A", "B", "C").enumerate(start: 1) /// ``` - #[func] + #[func(since = "0.2.0")] pub fn enumerate( self, /// The index returned for the first pair of the returned list. @@ -528,7 +528,7 @@ impl Array { /// This function is variadic, meaning that you can zip multiple arrays /// together at once: `{(1, 2).zip(("A", "B"), (10, 20))}` yields /// `{((1, "A", 10), (2, "B", 20))}`. - #[func] + #[func(since = "0.3.0")] pub fn zip( self, args: &mut Args, @@ -618,7 +618,7 @@ impl Array { /// #let array = (1, 2, 3, 4) /// #array.fold(0, (acc, x) => acc + x) /// ``` - #[func] + #[func(since = "forever")] pub fn fold( self, engine: &mut Engine, @@ -637,7 +637,7 @@ impl Array { } /// Sums all items (works for all types that can be added). - #[func] + #[func(since = "0.3.0")] pub fn sum( self, /// What to return if the array is empty. Must be set if the array can @@ -658,7 +658,7 @@ impl Array { /// Calculates the product of all items (works for all types that can be /// multiplied). - #[func] + #[func(since = "0.3.0")] pub fn product( self, /// What to return if the array is empty. Must be set if the array can @@ -678,7 +678,7 @@ impl Array { } /// Whether the given function returns `{true}` for any item in the array. - #[func] + #[func(since = "forever")] pub fn any( self, engine: &mut Engine, @@ -696,7 +696,7 @@ impl Array { } /// Whether the given function returns `{true}` for all items in the array. - #[func] + #[func(since = "forever")] pub fn all( self, engine: &mut Engine, @@ -714,7 +714,7 @@ impl Array { } /// Combine all nested arrays into a single flat one. - #[func] + #[func(since = "forever")] pub fn flatten(self) -> Array { let mut flat = EcoVec::with_capacity(self.0.len()); for item in self { @@ -728,7 +728,7 @@ impl Array { } /// Return a new array with the same items, but in reverse order. - #[func(title = "Reverse")] + #[func(title = "Reverse", since = "forever")] pub fn rev(self) -> Array { self.into_iter().rev().collect() } @@ -738,7 +738,7 @@ impl Array { /// ```example /// #(1, 1, 2, 3, 2, 4, 5).split(2) /// ``` - #[func] + #[func(since = "forever")] pub fn split( &self, /// The value to split at. @@ -751,7 +751,7 @@ impl Array { } /// Combine all items in the array into one. - #[func] + #[func(since = "forever")] pub fn join( self, /// A value to insert between each item of the array. @@ -798,7 +798,7 @@ impl Array { /// ```example /// #("A", "B", "C").intersperse("-") /// ``` - #[func] + #[func(since = "0.8.0")] pub fn intersperse( self, /// The value that will be placed between each adjacent element. @@ -837,7 +837,7 @@ impl Array { /// #array.chunks(3) \ /// #array.chunks(3, exact: true) /// ``` - #[func] + #[func(since = "0.11.0")] pub fn chunks( self, /// How many elements each chunk may at most contain. @@ -865,7 +865,7 @@ impl Array { /// #let array = (1, 2, 3, 4, 5, 6, 7, 8) /// #array.windows(5) /// ``` - #[func] + #[func(since = "0.12.0")] pub fn windows( self, /// How many elements each window will contain. @@ -896,7 +896,7 @@ impl Array { /// ) /// #array.sorted(key: it => (it.a, it.b)) /// ``` - #[func] + #[func(since = "forever")] pub fn sorted( self, engine: &mut Engine, @@ -1058,7 +1058,7 @@ impl Array { /// ```example /// #(3, 3, 1, 2, 3).dedup() /// ``` - #[func(title = "Deduplicate")] + #[func(title = "Deduplicate", since = "0.7.0")] pub fn dedup( self, engine: &mut Engine, @@ -1114,7 +1114,7 @@ impl Array { /// ("apples", 5), /// ).to-dict() /// ``` - #[func] + #[func(since = "0.12.0")] pub fn to_dict(self) -> StrResult { self.into_iter() .map(|value| { @@ -1124,7 +1124,7 @@ impl Array { })?; if let [key, value] = pair.as_slice() { let key = key.clone().cast::().map_err(|_| { - eco_format!("expected key of type str, found {}", value.ty()) + eco_format!("expected key of type string, found {}", key.ty()) })?; Ok((key, value.clone())) } else { @@ -1151,7 +1151,7 @@ impl Array { /// #let array = (2, 1, 4, 3) /// #array.reduce((acc, x) => calc.max(acc, x)) /// ``` - #[func] + #[func(since = "0.12.0")] pub fn reduce( self, engine: &mut Engine, diff --git a/crates/typst-library/src/foundations/auto.rs b/crates/typst-library/src/foundations/auto.rs index c5e6662c7f4a..8567b35d18da 100644 --- a/crates/typst-library/src/foundations/auto.rs +++ b/crates/typst-library/src/foundations/auto.rs @@ -17,7 +17,7 @@ use crate::foundations::{ /// contextual behaviour. A good example is the @text.dir[text direction] /// parameter. Setting it to `{auto}` lets Typst automatically determine the /// direction from the @text.lang[text language]. -#[ty(cast, name = "auto")] +#[ty(cast, name = "auto", since = "forever")] #[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct AutoValue; diff --git a/crates/typst-library/src/foundations/bool.rs b/crates/typst-library/src/foundations/bool.rs index 6eedf5931318..2b33c3af7ba8 100644 --- a/crates/typst-library/src/foundations/bool.rs +++ b/crates/typst-library/src/foundations/bool.rs @@ -13,7 +13,7 @@ use crate::foundations::{Repr, ty}; /// #true \ /// #(1 < 2) /// ``` -#[ty(cast, title = "Boolean")] +#[ty(cast, title = "Boolean", since = "forever")] type bool; impl Repr for bool { diff --git a/crates/typst-library/src/foundations/bytes.rs b/crates/typst-library/src/foundations/bytes.rs index ac1f59bd454d..90c27545a198 100644 --- a/crates/typst-library/src/foundations/bytes.rs +++ b/crates/typst-library/src/foundations/bytes.rs @@ -41,7 +41,7 @@ use crate::foundations::{Array, Reflect, Repr, Str, Value, cast, func, scope, ty /// #array(data.slice(0, 4)) \ /// #str(data.slice(1, 4)) /// ``` -#[ty(scope, cast)] +#[ty(scope, cast, since = "0.7.0")] #[derive(Clone, Hash)] pub struct Bytes(Arc>); @@ -239,7 +239,7 @@ impl Bytes { /// #bytes("Hello 😃") \ /// #bytes((123, 160, 22, 0)) /// ``` - #[func(constructor)] + #[func(constructor, since = "0.7.0")] pub fn construct( /// The value that should be converted to bytes. value: ToBytes, @@ -248,7 +248,7 @@ impl Bytes { } /// The length in bytes. - #[func(title = "Length")] + #[func(title = "Length", since = "0.7.0")] pub fn len(&self) -> usize { self.as_slice().len() } @@ -256,7 +256,7 @@ impl Bytes { /// Returns the byte at the specified index. Returns the default value if /// the index is out of bounds or fails with an error if no default value /// was specified. - #[func] + #[func(since = "0.7.0")] pub fn at( &self, /// The index at which to retrieve the byte. @@ -273,7 +273,7 @@ impl Bytes { /// Extracts a subslice of the bytes. Fails with an error if the start or /// end index is out of bounds. - #[func] + #[func(since = "0.7.0")] pub fn slice( &self, /// The start index (inclusive). diff --git a/crates/typst-library/src/foundations/calc.rs b/crates/typst-library/src/foundations/calc.rs index c2fae370d517..f4cfd94728bf 100644 --- a/crates/typst-library/src/foundations/calc.rs +++ b/crates/typst-library/src/foundations/calc.rs @@ -70,7 +70,7 @@ pub fn module() -> Module { /// #calc.abs(2fr) \ /// #calc.abs(decimal("-342.440")) /// ``` -#[func(title = "Absolute")] +#[func(title = "Absolute", since = "forever")] pub fn abs( /// The value whose absolute value to calculate. value: ToAbs, @@ -99,7 +99,7 @@ cast! { /// #calc.pow(2, 3) \ /// #calc.pow(decimal("2.5"), 2) /// ``` -#[func(title = "Power")] +#[func(title = "Power", since = "forever")] pub fn pow( span: Span, /// The base of the power. @@ -160,7 +160,7 @@ pub fn pow( /// ```example /// #calc.exp(1) /// ``` -#[func(title = "Exponential")] +#[func(title = "Exponential", since = "0.5.0")] pub fn exp( span: Span, /// The exponent of the power. @@ -190,7 +190,7 @@ pub fn exp( /// #calc.sqrt(16) \ /// #calc.sqrt(2.5) /// ``` -#[func(title = "Square Root")] +#[func(title = "Square Root", since = "forever")] pub fn sqrt( /// The number whose square root to calculate. Must be non-negative. value: Spanned, @@ -209,7 +209,7 @@ pub fn sqrt( /// #calc.root(16.0, 4) \ /// #calc.root(27.0, 3) /// ``` -#[func] +#[func(since = "0.11.0")] pub fn root( /// The expression to take the root of. radicand: f64, @@ -240,7 +240,7 @@ pub fn root( /// #calc.sin(1.5) \ /// #calc.sin(90deg) /// ``` -#[func(title = "Sine")] +#[func(title = "Sine", since = "forever")] pub fn sin( /// The angle whose sine to calculate. angle: AngleLike, @@ -260,7 +260,7 @@ pub fn sin( /// #calc.cos(1.5) \ /// #calc.cos(90deg) /// ``` -#[func(title = "Cosine")] +#[func(title = "Cosine", since = "forever")] pub fn cos( /// The angle whose cosine to calculate. angle: AngleLike, @@ -280,7 +280,7 @@ pub fn cos( /// #calc.tan(1.5) \ /// #calc.tan(90deg) /// ``` -#[func(title = "Tangent")] +#[func(title = "Tangent", since = "forever")] pub fn tan( /// The angle whose tangent to calculate. angle: AngleLike, @@ -298,7 +298,7 @@ pub fn tan( /// #calc.asin(0) \ /// #calc.asin(1) /// ``` -#[func(title = "Arcsine")] +#[func(title = "Arcsine", since = "forever")] pub fn asin( /// The number whose arcsine to calculate. Must be between $-1$ and $1$. value: Spanned, @@ -316,7 +316,7 @@ pub fn asin( /// #calc.acos(0) \ /// #calc.acos(1) /// ``` -#[func(title = "Arccosine")] +#[func(title = "Arccosine", since = "forever")] pub fn acos( /// The number whose arccosine to calculate. Must be between $-1$ and $1$. value: Spanned, @@ -334,7 +334,7 @@ pub fn acos( /// #calc.atan(0) \ /// #calc.atan(1) /// ``` -#[func(title = "Arctangent")] +#[func(title = "Arctangent", since = "forever")] pub fn atan( /// The number whose arctangent to calculate. value: Num, @@ -355,7 +355,7 @@ pub fn atan( /// #calc.atan2(1, 1) \ /// #calc.atan2(-2, -3) /// ``` -#[func(title = "Four-quadrant Arctangent")] +#[func(title = "Four-quadrant Arctangent", since = "0.3.0")] pub fn atan2( /// The $x$ coordinate. x: Num, @@ -374,7 +374,7 @@ pub fn atan2( /// #calc.sinh(0) \ /// #calc.sinh(1.5) /// ``` -#[func(title = "Hyperbolic Sine")] +#[func(title = "Hyperbolic Sine", since = "forever")] pub fn sinh( /// The hyperbolic angle whose hyperbolic sine to calculate. value: f64, @@ -391,7 +391,7 @@ pub fn sinh( /// #calc.cosh(0) \ /// #calc.cosh(1.5) /// ``` -#[func(title = "Hyperbolic Cosine")] +#[func(title = "Hyperbolic Cosine", since = "forever")] pub fn cosh( /// The hyperbolic angle whose hyperbolic cosine to calculate. value: f64, @@ -408,7 +408,7 @@ pub fn cosh( /// #calc.tanh(0) \ /// #calc.tanh(1.5) /// ``` -#[func(title = "Hyperbolic Tangent")] +#[func(title = "Hyperbolic Tangent", since = "forever")] pub fn tanh( /// The hyperbolic angle whose hyperbolic tangent to calculate. value: f64, @@ -425,7 +425,7 @@ pub fn tanh( /// #calc.asinh(0) \ /// #calc.asinh(1) /// ``` -#[func(title = "Inverse Hyperbolic Sine")] +#[func(title = "Inverse Hyperbolic Sine", since = "0.15.0")] pub fn asinh( /// The number whose inverse hyperbolic sine to calculate. value: f64, @@ -442,7 +442,7 @@ pub fn asinh( /// #calc.acosh(1) \ /// #calc.acosh(2.5) /// ``` -#[func(title = "Inverse Hyperbolic Cosine")] +#[func(title = "Inverse Hyperbolic Cosine", since = "0.15.0")] pub fn acosh( /// The number whose inverse hyperbolic cosine to calculate. Must be greater /// than or equal to $1$. @@ -464,7 +464,7 @@ pub fn acosh( /// #calc.atanh(0) \ /// #calc.atanh(0.5) /// ``` -#[func(title = "Inverse Hyperbolic Tangent")] +#[func(title = "Inverse Hyperbolic Tangent", since = "0.15.0")] pub fn atanh( /// The number whose inverse hyperbolic tangent to calculate. Must be /// between $-1$ and $1$ (exclusive). @@ -484,7 +484,7 @@ pub fn atanh( /// ```example /// #calc.log(100) /// ``` -#[func(title = "Logarithm")] +#[func(title = "Logarithm", since = "forever")] pub fn log( span: Span, /// The number whose logarithm to calculate. Must be strictly positive. @@ -525,7 +525,7 @@ pub fn log( /// ```example /// #calc.ln(calc.e) /// ``` -#[func(title = "Natural Logarithm")] +#[func(title = "Natural Logarithm", since = "0.5.0")] pub fn ln( span: Span, /// The number whose logarithm to calculate. Must be strictly positive. @@ -552,7 +552,7 @@ pub fn ln( /// ```example /// #calc.erf(0.2) /// ``` -#[func(title = "Error Function")] +#[func(title = "Error Function", since = "0.15.0")] pub fn erf( /// The number at which to calculate the error function. value: f64, @@ -565,7 +565,7 @@ pub fn erf( /// ```example /// #calc.fact(5) /// ``` -#[func(title = "Factorial")] +#[func(title = "Factorial", since = "0.3.0")] pub fn fact( /// The number whose factorial to calculate. Must be non-negative. number: u64, @@ -587,7 +587,7 @@ pub fn fact( /// ```example /// #calc.perm(5, 3) /// ``` -#[func(title = "Permutation")] +#[func(title = "Permutation", since = "0.3.0")] pub fn perm( /// The value of $n$: The number of items to choose from. Must be /// non-negative. @@ -634,7 +634,7 @@ fn fact_impl(start: u64, end: u64) -> Option { /// ```example /// #calc.binom(10, 5) /// ``` -#[func(title = "Binomial")] +#[func(title = "Binomial", since = "0.3.0")] pub fn binom( /// The value of $n$: The numbers of items to choose from. Must be /// non-negative. @@ -675,7 +675,7 @@ fn binom_impl(n: u64, k: u64) -> Option { /// ```example /// #calc.gcd(7, 42) /// ``` -#[func(title = "Greatest Common Divisor")] +#[func(title = "Greatest Common Divisor", since = "0.3.0")] pub fn gcd( /// The first integer. a: i64, @@ -697,7 +697,7 @@ pub fn gcd( /// ```example /// #calc.lcm(96, 13) /// ``` -#[func(title = "Least Common Multiple")] +#[func(title = "Least Common Multiple", since = "0.3.0")] pub fn lcm( /// The first integer. a: i64, @@ -728,7 +728,7 @@ pub fn lcm( /// #assert(calc.floor(3.14) == 3) /// #assert(calc.floor(decimal("-3.14")) == -4) /// ``` -#[func] +#[func(since = "forever")] pub fn floor( /// The number to round down. value: DecNum, @@ -755,7 +755,7 @@ pub fn floor( /// #assert(calc.ceil(3.14) == 4) /// #assert(calc.ceil(decimal("-3.14")) == -3) /// ``` -#[func] +#[func(since = "forever")] pub fn ceil( /// The number to round up. value: DecNum, @@ -782,7 +782,7 @@ pub fn ceil( /// #assert(calc.trunc(-3.7) == -3) /// #assert(calc.trunc(decimal("8493.12949582390")) == 8493) /// ``` -#[func(title = "Truncate")] +#[func(title = "Truncate", since = "0.3.0")] pub fn trunc( /// The number to truncate. value: DecNum, @@ -804,7 +804,7 @@ pub fn trunc( /// #assert(calc.fract(3) == 0) /// #assert(calc.fract(decimal("234.23949211")) == decimal("0.23949211")) /// ``` -#[func(title = "Fractional")] +#[func(title = "Fractional", since = "0.3.0")] pub fn fract( /// The number to truncate. value: DecNum, @@ -850,7 +850,7 @@ pub fn fract( /// #assert(calc.round(decimal("3333.45"), digits: -2) == decimal("3300")) /// #assert(calc.round(decimal("-48953.45"), digits: -3) == decimal("-49000")) /// ``` -#[func] +#[func(since = "forever")] pub fn round( /// The number to round. value: DecNum, @@ -885,7 +885,7 @@ pub fn round( /// #assert(calc.clamp(decimal("5.45"), 2, decimal("45.9")) == decimal("5.45")) /// #assert(calc.clamp(decimal("5.45"), decimal("6.75"), 12) == decimal("6.75")) /// ``` -#[func] +#[func(since = "forever")] pub fn clamp( span: Span, /// The number to clamp. @@ -917,7 +917,7 @@ pub fn clamp( /// #calc.min(1, -3, -5, 20, 3, 6) \ /// #calc.min("typst", "is", "cool") /// ``` -#[func(title = "Minimum")] +#[func(title = "Minimum", since = "forever")] pub fn min( span: Span, /// The sequence of values from which to extract the minimum. Must not be @@ -934,7 +934,7 @@ pub fn min( /// #calc.max(1, -3, -5, 20, 3, 6) \ /// #calc.max("typst", "is", "cool") /// ``` -#[func(title = "Maximum")] +#[func(title = "Maximum", since = "forever")] pub fn max( span: Span, /// The sequence of values from which to extract the maximum. Must not be @@ -973,7 +973,7 @@ fn minmax( /// #calc.even(5) \ /// #range(10).filter(calc.even) /// ``` -#[func] +#[func(since = "forever")] pub fn even( /// The number to check for evenness. value: i64, @@ -988,7 +988,7 @@ pub fn even( /// #calc.odd(5) \ /// #range(10).filter(calc.odd) /// ``` -#[func] +#[func(since = "forever")] pub fn odd( /// The number to check for oddness. value: i64, @@ -996,7 +996,7 @@ pub fn odd( value % 2 != 0 } -/// Calculates the remainder of two numbers. +/// Calculates the remainder of two numbers (signed modulo). /// /// The value `calc.rem(x, y)` always has the same sign as `x`, and is smaller /// in magnitude than `y`. @@ -1011,7 +1011,7 @@ pub fn odd( /// #calc.rem(-7, -3) \ /// #calc.rem(1.75, 0.5) /// ``` -#[func(title = "Remainder")] +#[func(title = "Remainder", since = "0.3.0", keywords = ["modulo", "modulus"])] pub fn rem( span: Span, /// The dividend of the remainder. @@ -1055,7 +1055,7 @@ pub fn rem( /// #calc.div-euclid(1.75, 0.5) \ /// #calc.div-euclid(decimal("1.75"), decimal("0.5")) /// ``` -#[func(title = "Euclidean Division")] +#[func(title = "Euclidean Division", since = "0.10.0")] pub fn div_euclid( span: Span, /// The dividend of the division. @@ -1080,7 +1080,8 @@ pub fn div_euclid( .at(span) } -/// This calculates the least nonnegative remainder of a division. +/// This calculates the least nonnegative remainder of a division (nonnegative +/// modulo). /// /// Warning: Due to a floating point round-off error, the remainder may equal /// the absolute value of the divisor if the dividend is much smaller in @@ -1098,7 +1099,7 @@ pub fn div_euclid( /// #calc.rem-euclid(1.75, 0.5) \ /// #calc.rem-euclid(decimal("1.75"), decimal("0.5")) /// ``` -#[func(title = "Euclidean Remainder", keywords = ["modulo", "modulus"])] +#[func(title = "Euclidean Remainder", since = "0.10.0", keywords = ["modulo", "modulus"])] pub fn rem_euclid( span: Span, /// The dividend of the remainder. @@ -1136,7 +1137,7 @@ pub fn rem_euclid( /// "quo"(14, 5) &= #calc.quo(14, 5) \ /// "quo"(3.46, 0.5) &= #calc.quo(3.46, 0.5) $ /// ``` -#[func(title = "Quotient")] +#[func(title = "Quotient", since = "0.3.0")] pub fn quo( span: Span, /// The dividend of the quotient. @@ -1186,7 +1187,7 @@ pub fn quo( /// #calc.norm(1, 2, -3, 0.5) \ /// #calc.norm(p: 3, 1, 2) /// ``` -#[func(title = "𝑝-Norm")] +#[func(title = "𝑝-Norm", since = "0.13.0")] pub fn norm( /// The value of $p$. Must be greater than zero. /// diff --git a/crates/typst-library/src/foundations/cast.rs b/crates/typst-library/src/foundations/cast.rs index a7e3d54749b7..44b886ea685d 100644 --- a/crates/typst-library/src/foundations/cast.rs +++ b/crates/typst-library/src/foundations/cast.rs @@ -522,6 +522,8 @@ cast! { "binary" => MathClass::Binary, /// An operator that can be both unary or binary like `+`. "vary" => MathClass::Vary, + /// An accent character. + "diacritic" => MathClass::Diacritic, } /// A type that contains a user-visible source portion and something that is diff --git a/crates/typst-library/src/foundations/content/element.rs b/crates/typst-library/src/foundations/content/element.rs index a3bd7329086d..9b837a867c28 100644 --- a/crates/typst-library/src/foundations/content/element.rs +++ b/crates/typst-library/src/foundations/content/element.rs @@ -11,7 +11,7 @@ use typst_utils::{DefSite, Static}; use crate::diag::SourceResult; use crate::engine::Engine; use crate::foundations::{ - Args, Content, ContentVtable, Func, NativeParamInfo, Repr, Scope, Selector, + Args, Content, ContentVtable, Func, NativeParamInfo, Repr, Scope, Selector, Since, StyleChain, Styles, Value, cast, }; use crate::text::{Lang, Region}; @@ -42,6 +42,11 @@ impl Element { self.vtable().title } + /// The version of Typst the element was introduced in. + pub fn since(&self) -> Option { + self.vtable().since.clone() + } + /// Documentation for the element (as Markdown). pub fn docs(self) -> &'static str { self.vtable().docs diff --git a/crates/typst-library/src/foundations/content/mod.rs b/crates/typst-library/src/foundations/content/mod.rs index dade3e952a1b..d1537191b746 100644 --- a/crates/typst-library/src/foundations/content/mod.rs +++ b/crates/typst-library/src/foundations/content/mod.rs @@ -31,8 +31,9 @@ use crate::foundations::{ }; use crate::introspection::Location; use crate::layout::{AlignElem, Alignment, Axes, Length, MoveElem, PadElem, Rel, Sides}; -use crate::model::{Destination, EmphElem, LinkElem, LinkMarker, StrongElem}; -use crate::pdf::{ArtifactElem, ArtifactKind}; +use crate::model::{ + ArtifactElem, ArtifactKind, Destination, EmphElem, LinkElem, LinkMarker, StrongElem, +}; use crate::text::UnderlineElem; /// A piece of document content. @@ -78,7 +79,7 @@ use crate::text::UnderlineElem; /// In the web app, you can hover over a content variable to see exactly which /// elements the content is composed of and what fields they have. /// Alternatively, you can inspect the output of the @repr function. -#[ty(scope, cast)] +#[ty(scope, cast, since = "forever")] #[derive(Clone, PartialEq, Hash)] #[repr(transparent)] pub struct Content(raw::RawContent); @@ -528,13 +529,13 @@ impl Content { /// element contained in this content. It can be used in set and show rules /// for the element. Can be compared with global functions to check whether /// you have a specific kind of element. - #[func] + #[func(since = "forever")] pub fn func(&self) -> Element { self.elem() } /// Whether the content has the specified field. - #[func] + #[func(since = "forever")] pub fn has( &self, /// The field to look for. @@ -557,7 +558,7 @@ impl Content { /// Access the specified field on the content. Returns the default value if /// the field does not exist or fails with an error if no default value was /// specified. - #[func] + #[func(since = "forever")] pub fn at( &self, /// The field to access. @@ -579,7 +580,7 @@ impl Content { /// height: 10cm, /// ).fields() /// ``` - #[func] + #[func(since = "0.5.0")] pub fn fields(&self) -> Dict { let mut dict = Dict::new(); for field in self.0.handle().fields() { @@ -598,7 +599,7 @@ impl Content { /// @reference:styling:show-rules[show rule], for other content it will be /// `{none}`. The resulting location can be used with @counter[counters], /// @state[state] and @query[queries]. - #[func] + #[func(since = "forever")] pub fn location(&self) -> Option { self.0.meta().location } diff --git a/crates/typst-library/src/foundations/content/vtable.rs b/crates/typst-library/src/foundations/content/vtable.rs index ff445977cfe4..650abef4990e 100644 --- a/crates/typst-library/src/foundations/content/vtable.rs +++ b/crates/typst-library/src/foundations/content/vtable.rs @@ -46,7 +46,7 @@ use crate::diag::SourceResult; use crate::engine::Engine; use crate::foundations::{ Args, CastInfo, Construct, Content, LazyElementStore, NativeElement, NativeScope, - Packed, Repr, Scope, Set, StyleChain, Styles, Value, + Packed, Repr, Scope, Set, Since, StyleChain, Styles, Value, }; use crate::text::{Lang, LocalName, Region}; @@ -83,6 +83,8 @@ pub struct ContentVtable { pub(super) name: &'static str, /// The element's title-cased name. pub(super) title: &'static str, + /// The version of Typst the element was introduced in. + pub(super) since: Option, /// The element's documentation (as Markdown). pub(super) docs: &'static str, /// Whether the element is defined in the source code. @@ -144,6 +146,7 @@ impl ContentVtable { pub const fn new( name: &'static str, title: &'static str, + since: Option, docs: &'static str, def_site: DefSite, fields: &'static [FieldVtable>], @@ -155,6 +158,7 @@ impl ContentVtable { ContentVtable { name, title, + since, docs, def_site, keywords: &[], diff --git a/crates/typst-library/src/foundations/datetime.rs b/crates/typst-library/src/foundations/datetime.rs index d212b1fde125..96f8b1ab7db2 100644 --- a/crates/typst-library/src/foundations/datetime.rs +++ b/crates/typst-library/src/foundations/datetime.rs @@ -140,8 +140,8 @@ use crate::foundations::{ /// will be stored as a plain date internally, meaning that you cannot use /// components such as `hour` or `minute`, which would only work on datetimes /// that have a specified time. -#[ty(scope, cast)] -#[derive(Debug, Copy, Clone, PartialEq, Hash)] +#[ty(scope, cast, since = "0.5.0")] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum Datetime { /// Representation as a date. Date(time::Date), @@ -262,7 +262,7 @@ impl Datetime { /// day: 3, /// ).display() /// ``` - #[func(constructor)] + #[func(constructor, since = "0.5.0")] pub fn construct( /// The year of the datetime. #[named] @@ -366,7 +366,7 @@ impl Datetime { /// Today's date is /// #datetime.today().display(). /// ``` - #[func] + #[func(since = "0.5.0")] pub fn today( engine: &mut Engine, /// An offset to apply to the current UTC date. If set to `{auto}`, the @@ -391,7 +391,7 @@ impl Datetime { /// `[[year]-[month]-[day] [hour]:[minute]:[second]]`. /// /// See the @datetime:format[format syntax] for more information. - #[func] + #[func(since = "0.5.0")] pub fn display( &self, /// The format used to display the datetime. @@ -418,7 +418,7 @@ impl Datetime { } /// The year if it was specified, or `{none}` for times without a date. - #[func] + #[func(since = "0.5.0")] pub fn year(&self) -> Option { match self { Self::Date(date) => Some(date.year()), @@ -428,7 +428,7 @@ impl Datetime { } /// The month if it was specified, or `{none}` for times without a date. - #[func] + #[func(since = "0.5.0")] pub fn month(&self) -> Option { match self { Self::Date(date) => Some(date.month().into()), @@ -438,7 +438,7 @@ impl Datetime { } /// The weekday (counting Monday as 1) or `{none}` for times without a date. - #[func] + #[func(since = "0.5.0")] pub fn weekday(&self) -> Option { match self { Self::Date(date) => Some(date.weekday().number_from_monday()), @@ -448,7 +448,7 @@ impl Datetime { } /// The day if it was specified, or `{none}` for times without a date. - #[func] + #[func(since = "0.5.0")] pub fn day(&self) -> Option { match self { Self::Date(date) => Some(date.day()), @@ -458,7 +458,7 @@ impl Datetime { } /// The hour if it was specified, or `{none}` for dates without a time. - #[func] + #[func(since = "0.5.0")] pub fn hour(&self) -> Option { match self { Self::Date(_) => None, @@ -468,7 +468,7 @@ impl Datetime { } /// The minute if it was specified, or `{none}` for dates without a time. - #[func] + #[func(since = "0.5.0")] pub fn minute(&self) -> Option { match self { Self::Date(_) => None, @@ -478,7 +478,7 @@ impl Datetime { } /// The second if it was specified, or `{none}` for dates without a time. - #[func] + #[func(since = "0.5.0")] pub fn second(&self) -> Option { match self { Self::Date(_) => None, @@ -488,7 +488,7 @@ impl Datetime { } /// The ordinal (day of the year), or `{none}` for times without a date. - #[func] + #[func(since = "0.5.0")] pub fn ordinal(&self) -> Option { match self { Self::Datetime(datetime) => Some(datetime.ordinal()), diff --git a/crates/typst-library/src/foundations/decimal.rs b/crates/typst-library/src/foundations/decimal.rs index cd919628f7d4..bf22ca85bfa4 100644 --- a/crates/typst-library/src/foundations/decimal.rs +++ b/crates/typst-library/src/foundations/decimal.rs @@ -89,7 +89,7 @@ use crate::foundations::{Repr, Str, cast, func, repr, scope, ty}; /// push a number's fractional digits beyond the limits described above, leading /// to rounding. When those two operations do not surpass the digit limits, they /// are fully precise. -#[ty(scope, cast)] +#[ty(scope, cast, since = "0.12.0")] #[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] pub struct Decimal(rust_decimal::Decimal); @@ -294,7 +294,7 @@ impl Decimal { /// ```example /// #decimal("1.222222222222222") /// ``` - #[func(constructor)] + #[func(constructor, since = "0.12.0")] pub fn construct( engine: &mut Engine, /// The value that should be converted to a decimal. diff --git a/crates/typst-library/src/foundations/dict.rs b/crates/typst-library/src/foundations/dict.rs index a609e50a35c3..d4d95f2c4f66 100644 --- a/crates/typst-library/src/foundations/dict.rs +++ b/crates/typst-library/src/foundations/dict.rs @@ -8,12 +8,13 @@ use ecow::{EcoString, eco_format}; use indexmap::IndexMap; use rustc_hash::FxBuildHasher; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use typst_syntax::is_ident; +use typst_syntax::{Spanned, is_ident}; use crate::diag::{At, Hint, HintedStrResult, SourceResult, StrResult}; use crate::engine::Engine; use crate::foundations::{ - Array, Context, Func, Module, Repr, Str, Value, array, cast, func, repr, scope, ty, + Array, Context, Func, Module, Repr, Str, Value, WorldBindingExt, array, cast, func, + repr, scope, ty, }; /// Create a new [`Dict`] from key-value pairs. @@ -75,7 +76,7 @@ pub use crate::__dict as dict; /// #dict.insert("city", "Berlin") /// #("name" in dict) /// ``` -#[ty(scope, cast, name = "dictionary")] +#[ty(scope, cast, name = "dictionary", since = "forever")] #[derive(Default, Clone, PartialEq)] pub struct Dict(Arc>); @@ -181,16 +182,29 @@ impl Dict { /// ```example /// #dictionary(sys).at("version") /// ``` - #[func(constructor)] + #[func(constructor, since = "forever")] pub fn construct( + engine: &mut Engine, /// The value that should be converted to a dictionary. - value: ToDict, - ) -> Dict { - value.0 + value: Spanned, + ) -> SourceResult { + let ToDict(module) = value.v; + let dict = module + .scope() + .iter() + .filter_map(|(key, binding)| { + // Filter out values that are in feature gated bindings and + // ignore any deprecation warnings that are emitted. + let guard = engine.world.silent_binding_guard(); + let val = binding.read(guard).ok()?; + Some((Str::from(key.clone()), val.clone())) + }) + .collect(); + Ok(dict) } /// The number of pairs in the dictionary. - #[func(title = "Length")] + #[func(title = "Length", since = "forever")] pub fn len(&self) -> usize { self.0.len() } @@ -204,7 +218,7 @@ impl Dict { /// /// Values may also be accessed with field syntax (e.g. `{(key: 42).key}`) /// if no default is needed. - #[func] + #[func(since = "forever")] pub fn at( &self, /// The key at which to retrieve the item. @@ -225,7 +239,7 @@ impl Dict { /// /// To insert multiple pairs at once, you can alternatively add another /// dictionary with the `+=` operator. - #[func] + #[func(since = "forever")] pub fn insert( &mut self, /// The key of the pair that should be inserted. @@ -237,7 +251,7 @@ impl Dict { } /// Removes a pair from the dictionary by key and return the value. - #[func] + #[func(since = "forever")] pub fn remove( &mut self, /// The key of the pair to remove. @@ -253,20 +267,20 @@ impl Dict { } /// Returns the keys of the dictionary as an array in insertion order. - #[func] + #[func(since = "forever")] pub fn keys(&self) -> Array { self.0.keys().cloned().map(Value::Str).collect() } /// Returns the values of the dictionary as an array in insertion order. - #[func] + #[func(since = "forever")] pub fn values(&self) -> Array { self.0.values().cloned().collect() } /// Returns the keys and values of the dictionary as an array of pairs. Each /// pair is represented as an array of length two. - #[func] + #[func(since = "forever")] pub fn pairs(&self) -> Array { self.0 .iter() @@ -298,7 +312,7 @@ impl Dict { /// } /// ``` /// ) - #[func] + #[func(since = "0.15.0")] pub fn filter( self, engine: &mut Engine, @@ -322,7 +336,7 @@ impl Dict { /// ```example /// #(a: 0, b: 1, c: 2).map(v => v + 1) /// ``` - #[func] + #[func(since = "0.15.0")] pub fn map( self, engine: &mut Engine, @@ -340,16 +354,14 @@ impl Dict { } /// A value that can be cast to dictionary. -pub struct ToDict(Dict); +/// Currently only modules are supported, and the conversion is deferred because +/// when accessing the scope of the module the list of active features needs to +/// be provided. +pub struct ToDict(Module); cast! { ToDict, - v: Module => Self(v - .scope() - .iter() - .map(|(k, b)| (Str::from(k.clone()), b.read().clone())) - .collect() - ), + m: Module => Self(m), } impl Debug for Dict { diff --git a/crates/typst-library/src/foundations/duration.rs b/crates/typst-library/src/foundations/duration.rs index 5d38a135b21e..06afd219636a 100644 --- a/crates/typst-library/src/foundations/duration.rs +++ b/crates/typst-library/src/foundations/duration.rs @@ -7,7 +7,7 @@ use time::ext::NumericalDuration; use crate::foundations::{Repr, func, repr, scope, ty}; /// Represents a positive or negative span of time. -#[ty(scope, cast)] +#[ty(scope, cast, since = "0.8.0")] #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct Duration(time::Duration); @@ -47,7 +47,7 @@ impl Duration { /// hours: 12, /// ).hours() /// ``` - #[func(constructor)] + #[func(constructor, since = "0.8.0")] pub fn construct( /// The number of seconds. #[named] @@ -84,7 +84,7 @@ impl Duration { /// This function returns the total duration represented in seconds as a /// floating-point number, rather than the seconds component of the /// duration. - #[func] + #[func(since = "0.8.0")] pub fn seconds(&self) -> f64 { self.0.as_seconds_f64() } @@ -94,7 +94,7 @@ impl Duration { /// This function returns the total duration represented in minutes as a /// floating-point number, rather than the minutes component of the /// duration. - #[func] + #[func(since = "0.8.0")] pub fn minutes(&self) -> f64 { self.seconds() / 60.0 } @@ -103,7 +103,7 @@ impl Duration { /// /// This function returns the total duration represented in hours as a /// floating-point number, rather than the hours component of the duration. - #[func] + #[func(since = "0.8.0")] pub fn hours(&self) -> f64 { self.seconds() / 3_600.0 } @@ -112,7 +112,7 @@ impl Duration { /// /// This function returns the total duration represented in days as a /// floating-point number, rather than the days component of the duration. - #[func] + #[func(since = "0.8.0")] pub fn days(&self) -> f64 { self.seconds() / 86_400.0 } @@ -121,7 +121,7 @@ impl Duration { /// /// This function returns the total duration represented in weeks as a /// floating-point number, rather than the weeks component of the duration. - #[func] + #[func(since = "0.8.0")] pub fn weeks(&self) -> f64 { self.seconds() / 604_800.0 } diff --git a/crates/typst-library/src/foundations/float.rs b/crates/typst-library/src/foundations/float.rs index 8d4dad588e6b..bcbe36d16d06 100644 --- a/crates/typst-library/src/foundations/float.rs +++ b/crates/typst-library/src/foundations/float.rs @@ -26,7 +26,7 @@ use crate::layout::Ratio; /// #1e4 \ /// #(10 / 4) /// ``` -#[ty(scope, cast, name = "float")] +#[ty(scope, cast, name = "float", since = "forever")] type f64; #[scope(ext)] @@ -56,7 +56,7 @@ impl f64 { /// #float("2.7") \ /// #float("1e5") /// ``` - #[func(constructor)] + #[func(constructor, since = "forever")] pub fn construct( /// The value that should be converted to a float. value: ToFloat, @@ -74,7 +74,7 @@ impl f64 { /// #float.is-nan(1) \ /// #float.is-nan(float.nan) /// ``` - #[func] + #[func(since = "0.11.0")] pub fn is_nan(self) -> bool { f64::is_nan(self) } @@ -89,7 +89,7 @@ impl f64 { /// #float.is-infinite(1) \ /// #float.is-infinite(float.inf) /// ``` - #[func] + #[func(since = "0.11.0")] pub fn is_infinite(self) -> bool { f64::is_infinite(self) } @@ -106,7 +106,7 @@ impl f64 { /// #(0.0).signum() \ /// #float.nan.signum() /// ``` - #[func] + #[func(since = "0.11.0")] pub fn signum(self) -> f64 { f64::signum(self) } @@ -117,7 +117,7 @@ impl f64 { /// #float.from-bytes(bytes((0, 0, 0, 0, 0, 0, 240, 63))) \ /// #float.from-bytes(bytes((63, 240, 0, 0, 0, 0, 0, 0)), endian: "big") /// ``` - #[func] + #[func(since = "0.12.0")] pub fn from_bytes( /// The bytes that should be converted to a float. /// @@ -154,7 +154,7 @@ impl f64 { /// #array(1.0.to-bytes(endian: "big")) \ /// #array(1.0.to-bytes()) /// ``` - #[func] + #[func(since = "0.12.0")] pub fn to_bytes( self, /// The endianness of the conversion. diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs index 0cdf9f83446f..1549f4f2441c 100644 --- a/crates/typst-library/src/foundations/func.rs +++ b/crates/typst-library/src/foundations/func.rs @@ -11,11 +11,11 @@ use either::Either; use typst_syntax::{Span, Spanned, SyntaxNode, ast}; use typst_utils::{DefSite, LazyHash, Static, singleton}; -use crate::diag::{At, SourceResult, StrResult, WarningSink, bail}; +use crate::diag::{At, HintedStrResult, SourceResult, StrResult, bail}; use crate::engine::Engine; use crate::foundations::{ - Args, Bytes, CastInfo, Content, Context, Element, IntoArgs, PluginFunc, Repr, Scope, - Selector, Type, Value, cast, scope, ty, + Args, BindingAccess, BindingGuard, Bytes, CastInfo, Content, Context, Element, + IntoArgs, PluginFunc, Repr, Scope, Selector, Since, Type, Value, cast, scope, ty, }; /// A mapping from argument values to a return value. @@ -135,7 +135,7 @@ use crate::foundations::{ /// The only exception are built-in methods like /// @array.push[`array.push(value)`]. These can modify the values they are /// called on. -#[ty(scope, cast, name = "function")] +#[ty(scope, cast, name = "function", since = "forever")] #[derive(Clone, Hash)] pub struct Func { /// The internal representation. @@ -186,6 +186,17 @@ impl Func { } } + /// The version of Typst the function was introduced in. + pub fn since(&self) -> Option { + match &self.inner { + FuncInner::Native(native) => native.since.clone(), + FuncInner::Element(elem) => elem.since(), + FuncInner::Closure(_) => None, + FuncInner::Plugin(_) => None, + FuncInner::With(with) => with.0.since(), + } + } + /// Documentation for the function (as Markdown). pub fn docs(&self) -> Option<&'static str> { match &self.inner { @@ -280,12 +291,14 @@ impl Func { pub fn field( &self, field: &str, - sink: impl WarningSink, - ) -> StrResult<&'static Value> { + guard: impl BindingGuard, + ) -> HintedStrResult<&'static Value> { let scope = self.scope().ok_or("cannot access fields on user-defined functions")?; match scope.get(field) { - Some(binding) => Ok(binding.read_checked(sink)), + Some(binding) => { + binding.read(guard).or_cannot(format_args!("access field `{field}`")) + } None => match self.name() { Some(name) => bail!("function `{name}` does not contain field `{field}`"), None => bail!("function does not contain field `{field}`"), @@ -380,7 +393,7 @@ impl Func { #[scope] impl Func { /// Returns a new function that has the given arguments pre-applied. - #[func] + #[func(since = "forever")] pub fn with( self, args: &mut Args, @@ -405,7 +418,7 @@ impl Func { /// == Subsection /// === Sub-subsection /// ``` - #[func] + #[func(since = "forever")] pub fn where_( self, args: &mut Args, @@ -626,6 +639,8 @@ pub struct NativeFuncData { pub name: &'static str, /// The function's title case name (e.g. `Align`). pub title: &'static str, + /// The version of Typst the function was introduced in. + pub since: Option, /// The documentation for this function as a string. pub docs: &'static str, /// Where the function is defined in the source code. diff --git a/crates/typst-library/src/foundations/int.rs b/crates/typst-library/src/foundations/int.rs index 0ca2f5f65f44..4b2c48267406 100644 --- a/crates/typst-library/src/foundations/int.rs +++ b/crates/typst-library/src/foundations/int.rs @@ -65,7 +65,7 @@ use crate::foundations::{ /// /// This also means that if you want to embed a negative integer in markup, you /// will need to use parentheses to group the negation operator: `[#(-6)]`. -#[ty(scope, cast, name = "int", title = "Integer")] +#[ty(scope, cast, name = "int", title = "Integer", since = "forever")] type i64; #[scope(ext)] @@ -95,7 +95,7 @@ impl i64 { /// #(int("27") + int("4")) \ /// #int("beef", base: 16) /// ``` - #[func(constructor)] + #[func(constructor, since = "forever")] pub fn construct( /// The value that should be converted to an integer. value: Spanned, @@ -156,7 +156,7 @@ impl i64 { /// #(-5).signum() \ /// #(0).signum() /// ``` - #[func] + #[func(since = "0.11.0")] pub fn signum(self) -> i64 { i64::signum(self) } @@ -170,7 +170,7 @@ impl i64 { /// #4.bit-not() \ /// #(-1).bit-not() /// ``` - #[func(title = "Bitwise NOT")] + #[func(title = "Bitwise NOT", since = "0.11.0")] pub fn bit_not(self) -> i64 { !self } @@ -183,7 +183,7 @@ impl i64 { /// ```example /// #128.bit-and(192) /// ``` - #[func(title = "Bitwise AND")] + #[func(title = "Bitwise AND", since = "0.11.0")] pub fn bit_and( self, /// The right-hand operand of the bitwise AND. @@ -200,7 +200,7 @@ impl i64 { /// ```example /// #64.bit-or(32) /// ``` - #[func(title = "Bitwise OR")] + #[func(title = "Bitwise OR", since = "0.11.0")] pub fn bit_or( self, /// The right-hand operand of the bitwise OR. @@ -217,7 +217,7 @@ impl i64 { /// ```example /// #64.bit-xor(96) /// ``` - #[func(title = "Bitwise XOR")] + #[func(title = "Bitwise XOR", since = "0.11.0")] pub fn bit_xor( self, /// The right-hand operand of the bitwise XOR. @@ -236,7 +236,7 @@ impl i64 { /// #33.bit-lshift(2) \ /// #(-1).bit-lshift(3) /// ``` - #[func(title = "Bitwise Left Shift")] + #[func(title = "Bitwise Left Shift", since = "0.11.0")] pub fn bit_lshift( self, /// The amount of bits to shift. Must not be negative. @@ -258,7 +258,7 @@ impl i64 { /// #(-8).bit-rshift(2) \ /// #(-8).bit-rshift(2, logical: true) /// ``` - #[func(title = "Bitwise Right Shift")] + #[func(title = "Bitwise Right Shift", since = "0.11.0")] pub fn bit_rshift( self, /// The amount of bits to shift. Must not be negative. @@ -312,7 +312,7 @@ impl i64 { /// #int.from-bytes(bytes((0, 0, 0, 0, 0, 0, 0, 1))) \ /// #int.from-bytes(bytes((1, 0, 0, 0, 0, 0, 0, 0)), endian: "big") /// ``` - #[func] + #[func(since = "0.12.0")] pub fn from_bytes( /// The bytes that should be converted to an integer. /// @@ -378,7 +378,7 @@ impl i64 { /// #array(10000.to-bytes(endian: "big")) \ /// #array(10000.to-bytes(size: 4)) /// ``` - #[func] + #[func(since = "0.12.0")] pub fn to_bytes( self, /// The endianness of the conversion. diff --git a/crates/typst-library/src/foundations/label.rs b/crates/typst-library/src/foundations/label.rs index 2729cd4961cb..82253d32fae9 100644 --- a/crates/typst-library/src/foundations/label.rs +++ b/crates/typst-library/src/foundations/label.rs @@ -46,7 +46,7 @@ use crate::foundations::{Repr, Str, bail, func, scope, ty}; /// /// Currently, labels can only be attached to elements in markup mode, not in /// code mode. This might change in the future. -#[ty(scope, cast)] +#[ty(scope, cast, since = "forever")] #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Label(PicoStr); @@ -73,7 +73,7 @@ impl Label { #[scope] impl Label { /// Creates a label from a string. - #[func(constructor)] + #[func(constructor, since = "forever")] pub fn construct( /// The name of the label. /// diff --git a/crates/typst-library/src/foundations/mod.rs b/crates/typst-library/src/foundations/mod.rs index f347c93b1051..c8ec232d6c01 100644 --- a/crates/typst-library/src/foundations/mod.rs +++ b/crates/typst-library/src/foundations/mod.rs @@ -132,7 +132,7 @@ pub(super) fn define(global: &mut Scope, inputs: Dict) { /// ```typ /// #panic("this is wrong") /// ``` -#[func(keywords = ["error"])] +#[func(since = "forever", keywords = ["error"])] pub fn panic( /// The values to panic with and display to the user. #[variadic] @@ -166,7 +166,7 @@ pub fn panic( /// ```typ /// #assert(1 < 2, message: "math broke") /// ``` -#[func(scope)] +#[func(scope, since = "forever")] pub fn assert( /// The condition that must be true for the assertion to pass. condition: bool, @@ -194,7 +194,7 @@ impl assert { /// ```typ /// #assert.eq(10, 10) /// ``` - #[func(title = "Assert Equal")] + #[func(title = "Assert Equal", since = "0.4.0")] pub fn eq( /// The first value to compare. left: Value, @@ -227,7 +227,7 @@ impl assert { /// ```typ /// #assert.ne(3, 4) /// ``` - #[func(title = "Assert Not Equal")] + #[func(title = "Assert Not Equal", since = "0.4.0")] pub fn ne( /// The first value to compare. left: Value, @@ -263,7 +263,7 @@ impl assert { /// #eval("(1, 2, 3, 4)").len() \ /// #eval("*Markup!*", mode: "markup") \ /// ``` -#[func(title = "Evaluate")] +#[func(title = "Evaluate", since = "forever")] pub fn eval( engine: &mut Engine, /// A string of Typst code to evaluate. diff --git a/crates/typst-library/src/foundations/module.rs b/crates/typst-library/src/foundations/module.rs index e54bff1e39c8..89a2d42aed5a 100644 --- a/crates/typst-library/src/foundations/module.rs +++ b/crates/typst-library/src/foundations/module.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use ecow::{EcoString, eco_format}; use typst_syntax::FileId; -use crate::diag::{StrResult, WarningSink, bail}; -use crate::foundations::{Content, Repr, Scope, Value, ty}; +use crate::diag::{HintedStrResult, bail}; +use crate::foundations::{BindingAccess, BindingGuard, Content, Repr, Scope, Value, ty}; /// A collection of variables and functions that are commonly related to a /// single theme. @@ -44,7 +44,7 @@ use crate::foundations::{Content, Repr, Scope, Value, ty}; /// Alternatively, it is possible to convert a module to a dictionary, and /// therefore access its contents dynamically, using the /// @dictionary.constructor[dictionary constructor]. -#[ty(cast)] +#[ty(cast, since = "forever")] #[derive(Clone, Hash)] pub struct Module { /// The module's name. @@ -136,9 +136,15 @@ impl Module { } /// Try to access a definition in the module. - pub fn field(&self, field: &str, sink: impl WarningSink) -> StrResult<&Value> { + pub fn field( + &self, + field: &str, + guard: impl BindingGuard, + ) -> HintedStrResult<&Value> { match self.scope().get(field) { - Some(binding) => Ok(binding.read_checked(sink)), + Some(binding) => { + binding.read(guard).or_cannot(format_args!("access field `{field}`")) + } None => match &self.name { Some(name) => bail!("module `{name}` does not contain `{field}`"), None => bail!("module does not contain `{field}`"), diff --git a/crates/typst-library/src/foundations/none.rs b/crates/typst-library/src/foundations/none.rs index 2d0cfd73172a..23454c0cfd56 100644 --- a/crates/typst-library/src/foundations/none.rs +++ b/crates/typst-library/src/foundations/none.rs @@ -21,7 +21,7 @@ use crate::foundations::{ /// ```example /// Not visible: #none /// ``` -#[ty(cast, name = "none")] +#[ty(cast, name = "none", since = "forever")] #[derive(Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct NoneValue; diff --git a/crates/typst-library/src/foundations/ops.rs b/crates/typst-library/src/foundations/ops.rs index 0aed5af4f3e6..7e72a61aac94 100644 --- a/crates/typst-library/src/foundations/ops.rs +++ b/crates/typst-library/src/foundations/ops.rs @@ -7,7 +7,7 @@ use typst_utils::Numeric; use crate::diag::{HintedStrResult, StrResult, bail}; use crate::foundations::{ - Datetime, IntoValue, Regex, Repr, SymbolElem, Value, format_str, + BindingGuard, Datetime, IntoValue, Regex, Repr, SymbolElem, Value, format_str, }; use crate::layout::{Alignment, Length, Rel}; use crate::text::TextElem; @@ -534,8 +534,8 @@ fn try_cmp_arrays(a: &[Value], b: &[Value]) -> StrResult { } /// Test whether one value is "in" another one. -pub fn in_(lhs: Value, rhs: Value) -> HintedStrResult { - if let Some(b) = contains(&lhs, &rhs) { +pub fn in_(guard: impl BindingGuard, lhs: Value, rhs: Value) -> HintedStrResult { + if let Some(b) = contains(guard, &lhs, &rhs) { Ok(Value::Bool(b)) } else { mismatch!("cannot apply 'in' to {} and {}", lhs, rhs) @@ -543,8 +543,12 @@ pub fn in_(lhs: Value, rhs: Value) -> HintedStrResult { } /// Test whether one value is "not in" another one. -pub fn not_in(lhs: Value, rhs: Value) -> HintedStrResult { - if let Some(b) = contains(&lhs, &rhs) { +pub fn not_in( + guard: impl BindingGuard, + lhs: Value, + rhs: Value, +) -> HintedStrResult { + if let Some(b) = contains(guard, &lhs, &rhs) { Ok(Value::Bool(!b)) } else { mismatch!("cannot apply 'not in' to {} and {}", lhs, rhs) @@ -552,13 +556,15 @@ pub fn not_in(lhs: Value, rhs: Value) -> HintedStrResult { } /// Test for containment. -pub fn contains(lhs: &Value, rhs: &Value) -> Option { +pub fn contains(guard: impl BindingGuard, lhs: &Value, rhs: &Value) -> Option { use Value::*; match (lhs, rhs) { (Str(a), Str(b)) => Some(b.as_str().contains(a.as_str())), (Dyn(a), Str(b)) => a.downcast::().map(|regex| regex.is_match(b)), (Str(a), Dict(b)) => Some(b.contains(a)), - (Str(a), Module(b)) => Some(b.scope().get(a).is_some()), + (Str(a), Module(b)) => { + Some(b.scope().get(a).is_some_and(|b| b.read(guard).is_ok())) + } (a, Array(b)) => Some(b.contains(a.clone())), _ => Option::None, diff --git a/crates/typst-library/src/foundations/path.rs b/crates/typst-library/src/foundations/path.rs index bce3ea39585e..4ba292c8fc7b 100644 --- a/crates/typst-library/src/foundations/path.rs +++ b/crates/typst-library/src/foundations/path.rs @@ -131,7 +131,7 @@ use crate::foundations::{Repr, Str, cast, func, scope, ty}; /// transferring paths across files in your project and packages. In the future, /// it may enable additional capabilities like checking for the existence of a /// file or enumerating files in a directory. -#[ty(scope, name = "path")] +#[ty(scope, name = "path", since = "0.14.2")] #[derive(Debug, Clone, PartialEq, Hash)] type RootedPath; @@ -148,7 +148,7 @@ impl RootedPath { /// // An absolute path with a leading slash. /// #path("/absolute/path/to/file.typ") /// ``` - #[func(constructor)] + #[func(constructor, since = "0.14.2")] pub fn construct( /// Converts a string or path to a path. /// diff --git a/crates/typst-library/src/foundations/plugin.rs b/crates/typst-library/src/foundations/plugin.rs index 62c28323d58b..a4fa4da7b419 100644 --- a/crates/typst-library/src/foundations/plugin.rs +++ b/crates/typst-library/src/foundations/plugin.rs @@ -145,7 +145,7 @@ use crate::loading::{DataSource, Load}; /// examples /// - Wrappers to help you write your plugin in Rust /// - A stubber for WASI -#[func(scope)] +#[func(scope, since = "0.8.0")] pub fn plugin( engine: &mut Engine, /// A path to a WebAssembly file or raw WebAssembly bytes. @@ -189,7 +189,7 @@ impl plugin { /// #assert.eq(base.get(), "[]") /// #assert.eq(mutated.get(), "[hello]") /// ``` - #[func] + #[func(since = "0.13.0")] pub fn transition( /// The plugin function to call. func: PluginFunc, diff --git a/crates/typst-library/src/foundations/repr.rs b/crates/typst-library/src/foundations/repr.rs index c9642d6f0ed9..63375c7ac71e 100644 --- a/crates/typst-library/src/foundations/repr.rs +++ b/crates/typst-library/src/foundations/repr.rs @@ -38,7 +38,7 @@ pub const MINUS_SIGN: &str = "\u{2212}"; /// /// #repr(x => x + 1) /// ``` -#[func(title = "Representation")] +#[func(title = "Representation", since = "forever")] pub fn repr( /// The value whose string representation to produce. value: Value, diff --git a/crates/typst-library/src/foundations/scope.rs b/crates/typst-library/src/foundations/scope.rs index 14d2768185ba..397cd206dec9 100644 --- a/crates/typst-library/src/foundations/scope.rs +++ b/crates/typst-library/src/foundations/scope.rs @@ -1,4 +1,4 @@ -use std::fmt::{self, Debug, Formatter}; +use std::fmt::{self, Debug, Display, Formatter}; use std::hash::{Hash, Hasher}; use ecow::{EcoString, eco_format}; @@ -7,11 +7,12 @@ use indexmap::map::Entry; use rustc_hash::FxBuildHasher; use typst_syntax::Span; -use crate::diag::{HintedStrResult, HintedString, StrResult, WarningSink, bail}; +use crate::diag::{HintedStrResult, HintedString, SourceDiagnostic, WarningSink, error}; +use crate::engine::Engine; use crate::foundations::{ Func, IntoValue, NativeElement, NativeFunc, NativeFuncData, NativeType, Value, }; -use crate::{Category, Library}; +use crate::{Category, Feature, Features, Library, World}; /// A stack of scopes. #[derive(Debug, Default, Clone)] @@ -42,8 +43,26 @@ impl<'a> Scopes<'a> { self.top = self.scopes.pop().expect("no pushed scope"); } - /// Try to access a binding immutably. - pub fn get(&self, var: &str) -> HintedStrResult<&Binding> { + /// Try to access a binding value immutably. + pub fn get(&self, var: &str, guard: impl BindingGuard) -> HintedStrResult<&Value> { + self.get_binding(var)? + .read(guard) + .or_cannot(format_args!("access variable `{var}`")) + } + + /// Try to access a binding value immutably in math. + pub fn get_in_math( + &self, + var: &str, + guard: impl BindingGuard, + ) -> HintedStrResult<&Value> { + self.get_binding_in_math(var)? + .read(guard) + .or_cannot(format_args!("access variable `{var}`")) + } + + /// Try to access a binding. + pub fn get_binding(&self, var: &str) -> HintedStrResult<&Binding> { std::iter::once(&self.top) .chain(self.scopes.iter().rev()) .find_map(|scope| scope.get(var)) @@ -58,22 +77,8 @@ impl<'a> Scopes<'a> { .ok_or_else(|| unknown_variable(var)) } - /// Try to access a binding mutably. - pub fn get_mut(&mut self, var: &str) -> HintedStrResult<&mut Binding> { - std::iter::once(&mut self.top) - .chain(&mut self.scopes.iter_mut().rev()) - .find_map(|scope| scope.get_mut(var)) - .ok_or_else(|| { - match self.base.and_then(|base| base.global.scope().get(var)) { - Some(_) => cannot_mutate_constant(var), - _ if var == "std" => cannot_mutate_constant(var), - _ => unknown_variable(var), - } - }) - } - - /// Try to access a binding immutably in math. - pub fn get_in_math(&self, var: &str) -> HintedStrResult<&Binding> { + /// Try to access a binding in math. + pub fn get_binding_in_math(&self, var: &str) -> HintedStrResult<&Binding> { std::iter::once(&self.top) .chain(self.scopes.iter().rev()) .find_map(|scope| scope.get(var)) @@ -93,6 +98,35 @@ impl<'a> Scopes<'a> { }) } + /// Try to access a binding mutably. + pub fn get_mut( + &mut self, + var: &str, + mut guard: impl BindingGuard, + ) -> HintedStrResult<&mut Value> { + std::iter::once(&mut self.top) + .chain(&mut self.scopes.iter_mut().rev()) + .find_map(|scope| { + Some( + scope + .get_mut(var)? + .write(&mut guard) + .or_cannot(format_args!("access variable `{var}`")), + ) + }) + .transpose()? + .ok_or_else(|| { + match self.base.and_then(|base| base.global.scope().get(var)) { + Some(binding) => match binding.read(guard) { + Err(err) => err.cannot(format_args!("access variable `{var}`")), + _ => cannot_mutate_constant(var), + }, + _ if var == "std" => cannot_mutate_constant(var), + _ => unknown_variable(var), + } + }) + } + /// Check if an std variable is shadowed. pub fn check_std_shadowed(&self, var: &str) -> bool { self.base.is_some_and(|base| base.global.scope().get(var).is_some()) @@ -180,9 +214,45 @@ impl Scope { } let mut binding = Binding::detached(value); - binding.category = self.category; + binding.init_info().category = self.category; self.bind(name.into(), binding) } + + /// Prelude a binding from a path. + /// + /// The path may not contain multiple bindings with different feature gates, + /// otherwise this function will panic. + pub(crate) fn prelude_path<'a, const N: usize>( + &'a mut self, + path: [&'static str; N], + ) -> Option<&'a mut Binding> { + let [first, path @ .., last] = path.as_slice() else { + panic!("prelude path must have at least two segments"); + }; + + let mut binding = self.get(first)?; + let mut path_feature = binding.feature(); + + for name in path.iter().chain([last]) { + binding = binding.value.scope()?.get(name)?; + + if let Some(new) = binding.feature() + && let Some(prev) = path_feature.replace(new) + && prev != new + { + panic!( + "cannot prelude from path {path:?} because it's \ + gated by two different features: `{prev}` and `{new}`" + ); + } + } + + let mut binding = binding.clone(); + if let Some(feature) = path_feature { + binding.with_feature(feature); + } + Some(self.bind((*last).into(), binding)) + } } /// Scope manipulation and access. @@ -201,7 +271,7 @@ impl Scope { } /// Try to access a binding immutably. - pub fn get(&self, var: &str) -> Option<&Binding> { + pub fn get<'a>(&'a self, var: &str) -> Option<&'a Binding> { self.map.get(var) } @@ -220,7 +290,7 @@ impl Debug for Scope { fn fmt(&self, f: &mut Formatter) -> fmt::Result { f.write_str("Scope ")?; f.debug_map() - .entries(self.map.iter().map(|(k, v)| (k, v.read()))) + .entries(self.map.iter().map(|(k, v)| (k, &v.value))) .finish() } } @@ -254,10 +324,15 @@ pub struct Binding { kind: BindingKind, /// A span associated with the binding. span: Span, - /// The category of the binding. - category: Option, - /// The deprecation information if this item is deprecated. - deprecation: Option>, + /// Whether this binding has additional checks that should be performed + /// before accessing it. This boolean is just an optimization, so the happy + /// path stays fast. The concrete checks are performed on the properties of + /// additional binding information in [`Self::info`]. + check_access: bool, + /// Infrequently accessed properties of the binding that are stored out of + /// band. These should only ever be set for built-in bindings in the + /// standard library. + info: Option>, } /// The different kinds of slots. @@ -276,8 +351,8 @@ impl Binding { value: value.into_value(), span, kind: BindingKind::Normal, - category: None, - deprecation: None, + check_access: false, + info: None, } } @@ -286,44 +361,90 @@ impl Binding { Self::new(value, Span::detached()) } + /// Sets the category of this binding. + pub fn with_category(&mut self, category: Category) -> &mut Self { + self.init_info().category = Some(category); + self + } + + /// Gates this binding behind the given [`Feature`]. + pub fn with_feature(&mut self, feature: Feature) -> &mut Self { + let info = self.init_info(); + info.feature = Some(feature); + self.check_access = info.has_checked_access(); + self + } + /// Marks this binding as deprecated, with the given `message`. - pub fn deprecated(&mut self, deprecation: Deprecation) -> &mut Self { - self.deprecation = Some(Box::new(deprecation)); + pub fn with_deprecation(&mut self, deprecation: Deprecation) -> &mut Self { + let info = self.init_info(); + info.deprecation = Some(deprecation); + self.check_access = info.has_checked_access(); self } - /// Read the value. - pub fn read(&self) -> &Value { - &self.value + fn init_info(&mut self) -> &mut BindingInfo { + self.info.get_or_insert_default() } - /// Read the value, checking for deprecation. + /// Try to read the binding value. + /// + /// The guard is used to check for deprecation and feature gates. + /// + /// # Example + /// + /// ``` + /// use typst_library::foundations::{Binding, BindingAccess, Value}; + /// use typst_library::diag::{At, SourceResult}; + /// use typst_library::engine::Engine; + /// use typst_syntax::Span; /// - /// As the `sink` - /// - pass `()` to ignore the message. - /// - pass `(&mut engine, span)` to emit a warning into the engine. - pub fn read_checked(&self, mut sink: impl WarningSink) -> &Value { - if let Some(message) = &self.deprecation { - sink.emit((**message).into()); + /// fn read_var(binding: &Binding, engine: &mut Engine, span: Span) -> SourceResult { + /// binding.read(engine.binding_guard(span)) + /// .or_cannot("access variable") + /// .at(span) + /// .cloned() + /// } + /// ``` + pub fn read(&self, guard: impl BindingGuard) -> Result<&Value, FeatureError> { + if self.check_access { + self.check_access(guard)?; } - &self.value + Ok(&self.value) } - /// Try to write to the value. - /// - /// This fails if the value is a read-only closure capture. - pub fn write(&mut self) -> StrResult<&mut Value> { - match self.kind { - BindingKind::Normal => Ok(&mut self.value), - BindingKind::Captured(capturer) => bail!( - "variables from outside the {} are \ - read-only and cannot be modified", - match capturer { - Capturer::Function => "function", - Capturer::Context => "context expression", - }, - ), + /// Try to write to the binding. + pub fn write( + &mut self, + guard: impl BindingGuard, + ) -> Result<&mut Value, BindingError> { + if self.check_access { + self.check_access(guard)?; + } + + if let BindingKind::Captured(capturer) = self.kind { + return Err(BindingError::Captured(capturer)); } + + Ok(&mut self.value) + } + + /// Check if the binding is gated behind a feature or if it is deprecated. + #[cold] + fn check_access(&self, mut guard: impl BindingGuard) -> Result<(), FeatureError> { + let Some(info) = &self.info else { return Ok(()) }; + + if let Some(feature) = info.feature + && !guard.features().is_enabled(feature) + { + return Err(FeatureError(feature)); + } + + if let Some(message) = info.deprecation { + guard.emit(message.into()); + } + + Ok(()) } /// Create a copy of the binding for closure capturing. @@ -339,14 +460,112 @@ impl Binding { self.span } - /// A deprecation message for the value, if any. - pub fn deprecation(&self) -> Option<&Deprecation> { - self.deprecation.as_deref() + /// The category of the binding, if any. + pub fn category(&self) -> Option { + self.info.as_ref()?.category } - /// The category of the value, if any. - pub fn category(&self) -> Option { - self.category + /// The feature that gates the binding, if any. + pub fn feature(&self) -> Option { + self.info.as_ref()?.feature + } + + /// The deprecation of the binding, if any. + pub fn deprecation(&self) -> Option { + self.info.as_ref()?.deprecation + } +} + +/// Infrequently accessed information related to a binding. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +struct BindingInfo { + /// The category of the binding. + category: Option, + /// A feature required to access this binding. + feature: Option, + /// The deprecation information if this item is deprecated. + deprecation: Option, +} + +impl BindingInfo { + /// Whether the binding info has a property set that requires checking + /// it when it's accessed. + fn has_checked_access(self) -> bool { + self.feature.is_some() || self.deprecation.is_some() + } +} + +/// There was an error accessing a binding. +/// +/// A [`Result`] can be converted into a [`HintedStrResult`] +/// using the [`BindingAccess`] trait, see [`Binding::read`]. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum BindingError { + /// The binding cannot be written to, because it is captured. + Captured(Capturer), + /// The feature that gates it isn't enabled. + /// + /// A [`Result`] can be converted into a + /// [`HintedStrResult`] using the [`BindingAccess`] trait, see + /// [`Binding::read`]. + Feature(FeatureError), +} + +impl BindingError { + pub fn cannot(self, what: impl Display) -> HintedString { + match self { + BindingError::Captured(capturer) => { + error!( + "variables from outside the {capturer} are \ + read-only and cannot be modified", + ) + } + BindingError::Feature(error) => error.cannot(what), + } + } +} + +impl From for BindingError { + fn from(v: FeatureError) -> Self { + Self::Feature(v) + } +} + +/// There was an error accessing a binding. +/// +/// A [`Result`] can be converted into a [`HintedStrResult`] +/// using the [`BindingAccess`] trait, see [`Binding::read`]. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct FeatureError(Feature); + +impl FeatureError { + pub fn cannot(self, what: impl Display) -> HintedString { + let Self(feature) = self; + error!( + "cannot {what} because the `{feature}` feature is not enabled"; + // TODO: Support for CLI-specific hints would be nice. + hint: "try enabling the `{feature}` feature"; + hint: "see https://typst.app/help/compiler-features for more details"; + ) + } +} + +/// Convert a [`BindingError`] to a [`HintedStrResult`] by providing a +/// description of what kind of binding couldn't be accessed. +pub trait BindingAccess { + /// Add a description of what kind of binding couldn't be accessed. + fn or_cannot(self, what: impl Display) -> HintedStrResult; +} + +impl BindingAccess for Result { + fn or_cannot(self, what: impl Display) -> HintedStrResult { + self.map_err(|err| err.cannot(what)) + } +} + +impl BindingAccess for Result { + fn or_cannot(self, what: impl Display) -> HintedStrResult { + self.map_err(|err| err.cannot(what)) } } @@ -359,6 +578,15 @@ pub enum Capturer { Context, } +impl Display for Capturer { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Capturer::Function => "function", + Capturer::Context => "context expression", + }) + } +} + /// Information about a deprecated binding. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Deprecation { @@ -424,7 +652,7 @@ fn cannot_mutate_constant(var: &str) -> HintedString { /// The error message when a variable wasn't found. #[cold] fn unknown_variable(var: &str) -> HintedString { - let mut res = HintedString::new(eco_format!("unknown variable: {var}")); + let mut res = HintedString::new(eco_format!("unknown variable `{var}`")); if var.contains('-') { res.hint(eco_format!( @@ -441,7 +669,7 @@ fn unknown_variable(var: &str) -> HintedString { /// The error message when a variable wasn't found it math. #[cold] fn unknown_variable_math(var: &str, in_global: bool) -> HintedString { - let mut res = HintedString::new(eco_format!("unknown variable: {var}")); + let mut res = HintedString::new(eco_format!("unknown variable `{var}`")); if matches!(var, "none" | "auto" | "false" | "true") { res.hint(eco_format!( @@ -472,3 +700,90 @@ fn unknown_variable_math(var: &str, in_global: bool) -> HintedString { res } + +/// Provides the currently enabled features when reading from a [`Binding`]. +pub trait BindingGuard: WarningSink { + /// The features enabled in the current [`crate::Library`]. + fn features(&self) -> &Features; + + /// Creates a [`BindingGuard`] that discards emitted warnings. + fn silent(&self) -> SilentBindingGuard { + SilentBindingGuard::new(self.features().clone()) + } +} + +impl BindingGuard for &mut T { + fn features(&self) -> &Features { + T::features(self) + } +} + +/// Create a [`BindingGuard`] from a [`World`]'s library, that discards all +/// emitted warnings. +pub trait WorldBindingExt { + /// Create a [`BindingGuard`] that discards emitted warnings. + fn silent_binding_guard(&self) -> SilentBindingGuard; +} + +impl WorldBindingExt for T { + fn silent_binding_guard(&self) -> SilentBindingGuard { + SilentBindingGuard { features: self.library().features.clone() } + } +} + +/// A [`BindingGuard`] that emits warnings to the engine's sink. +pub struct NormalBindingGuard<'x, 'y> { + pub engine: &'x mut Engine<'y>, + pub span: Span, +} + +impl WarningSink for NormalBindingGuard<'_, '_> { + fn emit(&mut self, message: HintedString) { + self.engine.sink.warn( + SourceDiagnostic::warning(self.span, message.message()) + .with_hints(message.hints().iter().cloned()), + ); + } +} + +impl BindingGuard for NormalBindingGuard<'_, '_> { + fn features(&self) -> &Features { + &self.engine.library.features + } +} + +/// A [`BindingGuard`] that discards emitted warnings. +#[derive(Clone)] +pub struct SilentBindingGuard { + features: Features, +} + +impl SilentBindingGuard { + pub fn new(features: Features) -> Self { + Self { features } + } +} + +impl WarningSink for SilentBindingGuard { + fn emit(&mut self, _message: HintedString) { + // Just discard warnings. + } +} + +impl WarningSink for &SilentBindingGuard { + fn emit(&mut self, _message: HintedString) { + // Just discard warnings. + } +} + +impl BindingGuard for SilentBindingGuard { + fn features(&self) -> &Features { + &self.features + } +} + +impl BindingGuard for &SilentBindingGuard { + fn features(&self) -> &Features { + &self.features + } +} diff --git a/crates/typst-library/src/foundations/selector.rs b/crates/typst-library/src/foundations/selector.rs index b4399fb66d7f..18aa955a21f3 100644 --- a/crates/typst-library/src/foundations/selector.rs +++ b/crates/typst-library/src/foundations/selector.rs @@ -71,7 +71,7 @@ pub use crate::__select_where as select_where; /// == So will this /// === But this will not. /// ``` -#[ty(scope, cast)] +#[ty(scope, cast, since = "0.3.0")] #[derive(Debug, Clone, PartialEq, Hash)] pub enum Selector { /// Matches a specific type of element. @@ -163,7 +163,7 @@ impl Selector { /// - A `{

` /// and headings turn into `

` and lower (a level 1 heading thus turns into /// `

`, a level 2 heading into `

`, etc). -#[elem(Locatable, Tagged, Synthesize, Count, ShowSet, LocalName, Refable, Outlinable)] +#[elem( + since = "forever", + Locatable, + Tagged, + Synthesize, + Count, + ShowSet, + LocalName, + Refable, + Outlinable +)] pub struct HeadingElem { /// The absolute nesting depth of the heading, starting from one. If set to /// `{auto}`, it is computed from `{offset + depth}`. diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs index 5bc4387560a2..ca4118994971 100644 --- a/crates/typst-library/src/model/link.rs +++ b/crates/typst-library/src/model/link.rs @@ -42,6 +42,25 @@ use crate::text::{LocalName, TextElem}; /// This function also has dedicated syntax: Text that starts with `http://` or /// `https://` is automatically turned into a link. /// +/// To avoid automatic creation of a link, you can put the text in a +/// @str[string]. To embed the string in markup, prefix it with a hash. +/// Alternatively, if the link-like text is computer code, you may also put it +/// in a @raw[`raw` element]. Note that, in both cases, the text may remain +/// clickable in PDF because some PDF readers auto-detect links. +/// +/// ```example +/// #show link: set text(blue) +/// +/// // Automatic link +/// https://example.com +/// +/// // String, not a link +/// #"https://example.com" +/// +/// // Raw, not a link +/// `https://*.com` +/// ``` +/// /// = Hyphenation /// If you enable hyphenation or justification, by default, it will not apply to /// links to prevent unwanted hyphenation in URLs. You can opt out of this @@ -102,7 +121,7 @@ use crate::text::{LocalName, TextElem}; /// generated. /// /// = Links in bundle export -/// In @reference:bundle[bundle export], linking still works as usual. For +/// In @format.bundle[bundle export], linking still works as usual. For /// instance, if you attach a label to an element in one document, links in /// other documents can reference that label. In addition, documents and assets /// are also directly linkable. To link to a full document or asset, you can @@ -144,7 +163,7 @@ use crate::text::{LocalName, TextElem}; /// into the built-in link handling. That said, in HTML export, depending on /// your use case, it may be possible to adjust the built-in link handling with /// a show rule on `{html.elem.where(tag: "a")}`. -#[elem(Locatable)] +#[elem(since = "forever", Locatable)] pub struct LinkElem { /// The destination the link points to. /// diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs index 4c8784f1302a..4584949a01e4 100644 --- a/crates/typst-library/src/model/list.rs +++ b/crates/typst-library/src/model/list.rs @@ -40,7 +40,7 @@ use crate::text::TextElem; /// followed by a space to create a list item. A list item can contain multiple /// paragraphs and other block-level content. All content that is indented more /// than an item's marker becomes part of that item. -#[elem(scope, title = "Bullet List", Locatable, Tagged)] +#[elem(scope, title = "Bullet List", since = "forever", Locatable, Tagged)] pub struct ListElem { /// Defines the default @list.spacing[spacing] of the list. If it is /// `{false}`, the items are spaced apart with @@ -163,7 +163,7 @@ impl ListElem { } /// A bullet list item. -#[elem(name = "item", title = "Bullet List Item", Tagged)] +#[elem(name = "item", title = "Bullet List Item", since = "0.4.0", Tagged)] pub struct ListItem { /// The item's body. #[required] diff --git a/crates/typst-library/src/model/mod.rs b/crates/typst-library/src/model/mod.rs index 15f3195c6f4a..eef3a92ccad6 100644 --- a/crates/typst-library/src/model/mod.rs +++ b/crates/typst-library/src/model/mod.rs @@ -1,6 +1,6 @@ //! Structuring elements that define the document model. -mod asset; +mod accessibility; mod bibliography; mod cite; mod divider; @@ -24,7 +24,7 @@ mod table; mod terms; mod title; -pub use self::asset::*; +pub use self::accessibility::*; pub use self::bibliography::*; pub use self::cite::*; pub use self::divider::*; @@ -47,15 +47,11 @@ pub use self::terms::*; pub use self::title::*; use crate::foundations::Scope; -use crate::{Feature, Features}; /// Hook up all `model` definitions. -pub fn define(global: &mut Scope, features: &Features) { +pub fn define(global: &mut Scope) { global.start_category(crate::Category::Model); global.define_elem::(); - if features.is_enabled(Feature::Bundle) { - global.define_elem::(); - } global.define_elem::(); global.define_elem::(); global.define_elem::(); diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs index a802a071f810..fcb54d8d195e 100644 --- a/crates/typst-library/src/model/numbering.rs +++ b/crates/typst-library/src/model/numbering.rs @@ -51,7 +51,7 @@ use crate::foundations::{Context, Func, Str, Value, cast, func}; /// = Second heading /// = Third heading /// ``` -#[func] +#[func(since = "forever")] pub fn numbering( engine: &mut Engine, context: Tracked, diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 3d6cf972ed7d..357697122d17 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -20,8 +20,8 @@ use crate::layout::{ Abs, Axes, BlockBody, BlockElem, BoxElem, Dir, Em, Fr, HElem, Length, Region, Rel, RepeatElem, Sides, }; +use crate::model::PdfMarkerTag; use crate::model::{HeadingElem, NumberingPattern, ParElem, Refable}; -use crate::pdf::PdfMarkerTag; use crate::text::{LocalName, SpaceElem, TextElem}; /// A table of contents, figures, or other elements. @@ -147,7 +147,7 @@ use crate::text::{LocalName, SpaceElem, TextElem}; /// = About ACME Corp. /// == History /// ``` -#[elem(scope, keywords = ["Table of Contents", "toc"], ShowSet, LocalName, Locatable, Tagged)] +#[elem(scope, since = "forever", keywords = ["Table of Contents", "toc"], ShowSet, LocalName, Locatable, Tagged)] pub struct OutlineElem { /// The title of the outline. /// @@ -470,7 +470,14 @@ pub trait Outlinable: Refable { /// With show-set and show rules on outline entries, you can richly customize /// the outline's appearance. See the /// @outline:styling-the-outline[section on styling the outline] for details. -#[elem(scope, name = "entry", title = "Outline Entry", Locatable, Tagged)] +#[elem( + scope, + name = "entry", + title = "Outline Entry", + since = "0.6.0", + Locatable, + Tagged +)] pub struct OutlineEntry { /// The nesting level of this outline entry. Starts at `{1}` for top-level /// entries. @@ -529,7 +536,7 @@ impl OutlineEntry { /// indented, but the inner contents are simply offset from the prefix by /// the specified `gap`, rather than aligning outline-wide. For a visual /// explanation, see @outline.indent. - #[func(contextual)] + #[func(contextual, since = "0.13.0")] pub fn indented( &self, engine: &mut Engine, @@ -626,7 +633,7 @@ impl OutlineEntry { /// This also appends the element's supplement in case of figures or /// equations. For instance, it would output `1.1` for a heading, but /// `Figure 1` for a figure, as is usual for outlines. - #[func(contextual)] + #[func(contextual, since = "0.13.0")] pub fn prefix( &self, engine: &mut Engine, @@ -646,7 +653,7 @@ impl OutlineEntry { /// Creates the default inner content of the entry. /// /// This includes the body, the fill, and page number. - #[func(contextual)] + #[func(contextual, since = "0.13.0")] pub fn inner( &self, engine: &mut Engine, @@ -661,14 +668,14 @@ impl OutlineEntry { /// The content which is displayed in place of the referred element at its /// entry in the outline. For a heading, this is its @heading.body[`body`]; /// for a figure a caption and for equations, it is empty. - #[func] + #[func(since = "0.13.0")] pub fn body(&self) -> StrResult { Ok(self.outlinable()?.body()) } /// The page number of this entry's element, formatted with the numbering /// set for the referenced page. - #[func(contextual)] + #[func(contextual, since = "0.13.0")] pub fn page( &self, engine: &mut Engine, diff --git a/crates/typst-library/src/model/par.rs b/crates/typst-library/src/model/par.rs index 041c0fe09bb6..3b01299644f8 100644 --- a/crates/typst-library/src/model/par.rs +++ b/crates/typst-library/src/model/par.rs @@ -95,7 +95,7 @@ use crate::model::Numbering; /// let $a$ be the smallest of the /// three integers. Then, we ... /// ``` -#[elem(scope, title = "Paragraph", Locatable, Tagged)] +#[elem(scope, title = "Paragraph", since = "forever", Locatable, Tagged)] pub struct ParElem { /// The spacing between lines. /// @@ -722,7 +722,7 @@ impl Fold for FirstLineIndent { /// = Syntax /// Instead of calling this function, you can insert a blank line into your /// markup to create a paragraph break. -#[elem(title = "Paragraph Break", Unlabellable)] +#[elem(title = "Paragraph Break", since = "forever", Unlabellable)] pub struct ParbreakElem {} impl ParbreakElem { @@ -787,7 +787,7 @@ impl Unlabellable for Packed {} /// @par.line.number-margin[margin]. In addition, you can control whether the /// numbering is reset on each page through the /// @par.line.numbering-scope[`numbering-scope`] option. -#[elem(name = "line", title = "Paragraph Line", keywords = ["line numbering"], Construct, Locatable)] +#[elem(name = "line", title = "Paragraph Line", since = "0.12.0", keywords = ["line numbering"], Construct, Locatable)] pub struct ParLine { /// How to number each line. Accepts a /// @numbering[numbering pattern or function] taking a single number. diff --git a/crates/typst-library/src/model/quote.rs b/crates/typst-library/src/model/quote.rs index f826332f4b9d..4dd27a81d0de 100644 --- a/crates/typst-library/src/model/quote.rs +++ b/crates/typst-library/src/model/quote.rs @@ -41,7 +41,7 @@ use crate::text::{SmartQuotes, SpaceElem, TextElem}; /// flame of Udûn. Go back to the Shadow! You cannot pass. /// ] /// ``` -#[elem(Locatable, Tagged, ShowSet)] +#[elem(since = "0.9.0", Locatable, Tagged, ShowSet)] pub struct QuoteElem { /// Whether this is a block quote. /// diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index b22973922a04..0424694878a5 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -135,7 +135,7 @@ use crate::text::TextElem; /// In @beginning we prove @pythagoras. /// $ a^2 + b^2 = c^2 $ /// ``` -#[elem(title = "Reference", Locatable, Tagged, Synthesize)] +#[elem(title = "Reference", since = "forever", Locatable, Tagged, Synthesize)] pub struct RefElem { /// The target label that should be referenced. /// diff --git a/crates/typst-library/src/model/strong.rs b/crates/typst-library/src/model/strong.rs index bf06594c74b1..4e437e51d833 100644 --- a/crates/typst-library/src/model/strong.rs +++ b/crates/typst-library/src/model/strong.rs @@ -18,7 +18,7 @@ use crate::foundations::{Content, elem}; /// simply enclose it in stars/asterisks (`*`). Note that this only works at /// word boundaries. To strongly emphasize part of a word, you have to use the /// function. -#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"], Locatable, Tagged)] +#[elem(title = "Strong Emphasis", since = "forever", keywords = ["bold", "weight"], Locatable, Tagged)] pub struct StrongElem { /// The delta to apply on the font weight. /// diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index b7b36f48f8db..a08e132cb7e7 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -2,6 +2,7 @@ use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; use ecow::EcoString; +use typst_macros::Cast; use typst_utils::NonZeroExt; use crate::diag::{HintedStrResult, HintedString, SourceResult, bail}; @@ -15,7 +16,6 @@ use crate::layout::{ Length, OuterHAlignment, OuterVAlignment, Rel, Sides, TrackSizings, }; use crate::model::Figurable; -use crate::pdf::TableCellKind; use crate::text::LocalName; use crate::visualize::{Paint, Stroke}; @@ -133,7 +133,7 @@ use crate::visualize::{Paint, Stroke}; /// visually, you should consider making the core information in your table /// available as text as well. You can do this by wrapping your table in a /// @figure[figure] and using its caption to summarize the table's content. -#[elem(scope, Locatable, Tagged, Synthesize, LocalName, Figurable)] +#[elem(scope, since = "forever", Locatable, Tagged, Synthesize, LocalName, Figurable)] pub struct TableElem { /// The column sizes. See the @grid:track-size[grid documentation] for more /// information on track sizing. @@ -455,7 +455,7 @@ impl TryFrom for TableItem { /// header cell. Likewise, you can use @pdf.data-cell to mark cells in this /// function as data cells. Note that these functions are not final and thus /// only available when you enable the `a11y-extras` feature (see the -/// @pdf[PDF module documentation] for details). +/// @pdf[PDF format documentation] for details). /// /// ```example /// #set page(height: 11.5em) @@ -491,7 +491,7 @@ impl TryFrom for TableItem { /// [7.34], [57], [2], /// ) /// ``` -#[elem(name = "header", title = "Table Header")] +#[elem(name = "header", title = "Table Header", since = "0.11.0")] pub struct TableHeader { /// Whether this header should be repeated across pages. #[default(true)] @@ -521,7 +521,7 @@ pub struct TableHeader { /// other information that should be visible on every page. /// /// No other table cells may be placed after the footer. -#[elem(name = "footer", title = "Table Footer")] +#[elem(name = "footer", title = "Table Footer", since = "0.11.0")] pub struct TableFooter { /// Whether this footer should be repeated across pages. #[default(true)] @@ -564,7 +564,7 @@ pub struct TableFooter { /// [19:00], [Day 1 Attendee Mixer], /// ) /// ``` -#[elem(name = "hline", title = "Table Horizontal Line")] +#[elem(name = "hline", title = "Table Horizontal Line", since = "0.11.0")] pub struct TableHLine { /// The row above which the horizontal line is placed (zero-indexed). /// Functions identically to the `y` field in @grid.hline.y[`grid.hline`]. @@ -609,7 +609,7 @@ pub struct TableHLine { /// @table.stroke[table's `stroke`] field or /// @table.cell.stroke[`table.cell`'s `stroke`] field instead if the line you /// want to place is part of all your tables' designs. -#[elem(name = "vline", title = "Table Vertical Line")] +#[elem(name = "vline", title = "Table Vertical Line", since = "0.11.0")] pub struct TableVLine { /// The column before which the vertical line is placed (zero-indexed). /// Functions identically to the `x` field in @grid.vline. @@ -729,7 +729,7 @@ pub struct TableVLine { /// [Vikram], [49], [Perseverance], /// ) /// ``` -#[elem(name = "cell", title = "Table Cell")] +#[elem(name = "cell", title = "Table Cell", since = "0.11.0")] pub struct TableCell { /// The cell's body. #[required] @@ -801,3 +801,45 @@ impl From for TableCell { value.unpack::().unwrap_or_else(Self::new) } } + +/// Describes what kind of table cell this is. +/// +/// The full Typst table model only supports header and footer rows, this is +/// currently only used for PDF export. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub enum TableCellKind { + Header(NonZeroU32, TableHeaderScope), + Footer, + #[default] + Data, +} + +/// Which table track a header cell labels. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum TableHeaderScope { + /// The header cell refers to both the row and the column. + Both, + /// The header cell refers to the column. + #[default] + Column, + /// The header cell refers to the row. + Row, +} + +impl TableHeaderScope { + pub fn refers_to_column(&self) -> bool { + match self { + TableHeaderScope::Both => true, + TableHeaderScope::Column => true, + TableHeaderScope::Row => false, + } + } + + pub fn refers_to_row(&self) -> bool { + match self { + TableHeaderScope::Both => true, + TableHeaderScope::Column => false, + TableHeaderScope::Row => true, + } + } +} diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs index e82dd94460c3..297800d240aa 100644 --- a/crates/typst-library/src/model/terms.rs +++ b/crates/typst-library/src/model/terms.rs @@ -1,6 +1,5 @@ -use crate::diag::{bail, warning}; use crate::foundations::{ - Array, Content, NativeElement, Packed, Reflect, Smart, Styles, cast, elem, scope, + Content, NativeElement, Packed, Smart, Styles, cast, elem, scope, }; use crate::layout::{Em, HElem, Length}; use crate::model::{ListItemLike, ListLike}; @@ -21,7 +20,7 @@ use crate::model::{ListItemLike, ListLike}; /// = Syntax /// This function also has dedicated syntax: Starting a line with a slash, /// followed by a term, a colon and a description creates a term list item. -#[elem(scope, title = "Term List", Locatable, Tagged)] +#[elem(scope, title = "Term List", since = "forever", Locatable, Tagged)] pub struct TermsElem { /// Defines the default @terms.spacing[spacing] of the term list. If it is /// `{false}`, the items are spaced apart with @@ -97,19 +96,6 @@ pub struct TermsElem { /// ) [/ #product: Born in #year.] /// ``` #[variadic] - #[parse( - for item in &args.items { - if item.name.is_none() && Array::castable(&item.value.v) { - engine.sink.warn(warning!( - item.value.span, - "implicit conversion from array to `terms.item` is deprecated"; - hint: "use `terms.item(term, description)` instead"; - hint: "this conversion was never documented and is being phased out"; - )); - } - } - args.all()? - )] pub children: Vec>, /// Whether we are currently within a term list. @@ -125,7 +111,7 @@ impl TermsElem { } /// A term list item. -#[elem(name = "item", title = "Term List Item", Tagged)] +#[elem(name = "item", title = "Term List Item", since = "0.4.0", Tagged)] pub struct TermItem { /// The term described by the list item. #[required] @@ -138,14 +124,6 @@ pub struct TermItem { cast! { TermItem, - array: Array => { - let mut iter = array.into_iter(); - let (term, description) = match (iter.next(), iter.next(), iter.next()) { - (Some(a), Some(b), None) => (a.cast()?, b.cast()?), - _ => bail!("array must contain exactly two entries"), - }; - Self::new(term, description) - }, v: Content => v.unpack::().map_err(|_| "expected term item or array")?, } diff --git a/crates/typst-library/src/model/title.rs b/crates/typst-library/src/model/title.rs index 69fb40fd8ffe..c5739992b422 100644 --- a/crates/typst-library/src/model/title.rs +++ b/crates/typst-library/src/model/title.rs @@ -27,7 +27,7 @@ use crate::text::{FontWeight, TextElem, TextSize}; /// = Introduction /// In recent years, ... /// ``` -#[elem(Locatable, Tagged, ShowSet)] +#[elem(since = "0.14.0", Locatable, Tagged, ShowSet)] pub struct TitleElem { /// The content of the title. /// diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs deleted file mode 100644 index 8146e24b7fae..000000000000 --- a/crates/typst-library/src/pdf/accessibility.rs +++ /dev/null @@ -1,375 +0,0 @@ -use std::num::NonZeroU32; - -use ecow::EcoString; -use typst_macros::{Cast, elem, func}; -use typst_utils::NonZeroExt; - -use crate::diag::SourceResult; -use crate::diag::bail; -use crate::engine::Engine; -use crate::foundations::{Args, Construct, Content, NativeElement, Smart}; -use crate::model::{TableCell, TableElem}; - -/// Marks content as a PDF artifact. -/// -/// Artifacts are parts of the document that are not meant to be read by -/// Assistive Technology (AT), such as screen readers. Typical examples include -/// purely decorative images that do not contribute to the meaning of the -/// document, watermarks, or repeated content such as page numbers. -/// -/// Typst will automatically mark certain content, such as page headers, -/// footers, backgrounds, and foregrounds, as artifacts. Likewise, paths and -/// shapes are automatically marked as artifacts, but their content is not. Line -/// numbers created using @par.line are automatically marked as artifacts, as -/// are repetitions of table headers and footers. -/// -/// Once something is marked as an artifact, you cannot make any of its contents -/// accessible again. If you need to mark only part of something as an artifact, -/// you may need to use this function multiple times. -/// -/// If you are unsure what constitutes an artifact, check the -/// @guides:accessibility:artifacts[Accessibility Guide]. -/// -/// In the future, this function may be moved out of the `pdf` module, making it -/// possible to hide content in HTML export from AT. -// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`? -#[elem(Tagged)] -pub struct ArtifactElem { - /// The artifact kind. - /// - /// You can improve accessibility by using the most specific artifact kind - /// available. Your choice will govern how the PDF reader treats the - /// artifact during reflow and content extraction (e.g. copy and paste). - /// - /// Artifact types have been introduced in various different PDF - /// specifications. Depending on which PDF version you target, Typst will - /// select the most appropriate artifact type using your selection here. - #[default(ArtifactKind::Other)] - pub kind: ArtifactKind, - - /// The content that is an artifact. - #[required] - pub body: Content, -} - -/// The type of artifact. -#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum ArtifactKind { - /// Repeats on the top of each page. - Header, - /// Repeats at the bottom of each page. - Footer, - /// Text or graphics in the back- or foreground of all pages. - Watermark, - /// Page numbers. Note that if your page numbers are contained in a footer - /// or header instead, the whole header or footer should an artifact of the - /// appropriate type. - PageNumber, - /// Line or paragraph numbers. - LineNumber, - /// Placeholders for areas in which there was content in another rendition - /// of the document which has since been removed. - Redaction, - /// Bates numbering. Note that if your Bates numbering is contained in a - /// footer or header instead, the whole header or footer should an artifact - /// of the appropriate type. - Bates, - /// Not part of the document, but rather the page it is printed on. An - /// example would be cut marks or color bars. - Page, - /// Artifacts arising from paginating the document not covered by other - /// artifact types. This category generally applies if this artifact would - /// not appear in your document if it was a website instead. If your - /// artifact is covered by other categories, prefer them over this. - PaginationOther, - /// Purely cosmetric content or typographical flourishes not contributing to - /// the document's content. - Layout, - /// Background of a page or a graphical element. This artifact kind was - /// added in PDF 1.7. However, due to requirements in the PDF 1.7 - /// specification that later specifications lifted, Typst only uses this - /// artifact type in PDF 2.0. If you use it in a PDF 1.7 or earlier, Typst - /// will use the `{"other"}` type instead. - Background, - /// Other artifacts. - #[default] - Other, -} - -/// A summary of the purpose and structure of a complex table. -/// -/// This will be available for Assistive Technology (AT), such as screen -/// readers, when exporting to PDF, but not for sighted readers of your file. -/// -/// This field is intended for instructions that help the user navigate the -/// table using AT. It is not an alternative description, so do not duplicate -/// the contents of the table within. Likewise, do not use this for the core -/// takeaway of the table. Instead, include that in the text around the table -/// or, even better, in a @figure.caption[figure caption]. -/// -/// If in doubt whether your table is complex enough to warrant a summary, err -/// on the side of not including one. If you are certain that your table is -/// complex enough, consider whether a sighted user might find it challenging. -/// They might benefit from the instructions you put here, so consider printing -/// them visibly in the document instead. -/// -/// The API of this feature is temporary. Hence, calling this function requires -/// enabling the `a11y-extras` feature flag at the moment. Even if this -/// functionality should be available without a feature flag in the future, the -/// summary will remain exclusive to PDF export. -/// -/// ```example -/// #figure( -/// pdf.table-summary( -/// // The summary just provides orientation and structural -/// // information for AT users. -/// summary: "The first two columns list the names of each participant. The last column contains cells spanning multiple rows for their assigned group.", -/// table( -/// columns: 3, -/// table.header[First Name][Given Name][Group], -/// [Mike], [Davis], table.cell(rowspan: 3)[Sales], -/// [Anna], [Smith], -/// [John], [Johnson], -/// [Sara], [Wilkins], table.cell(rowspan: 2)[Operations], -/// [Tom], [Brown], -/// ), -/// ), -/// // This is the key takeaway of the table, so we put it in the caption. -/// caption: [The Sales org now has a new member], -/// ) -/// ``` -#[func] -pub fn table_summary( - #[named] summary: Option, - /// The table. - table: TableElem, -) -> Content { - table.with_summary(summary).pack() -} - -/// Explicitly defines a cell as a header cell. -/// -/// Header cells help users of Assistive Technology (AT) understand and navigate -/// complex tables. When your table is correctly marked up with header cells, AT -/// can announce the relevant header information on-demand when entering a cell. -/// -/// By default, Typst will automatically mark all cells within @table.header as -/// header cells. They will apply to the columns below them. You can use that -/// function's @table.header.level[`level`] parameter to make header cells -/// labelled by other header cells. -/// -/// The `pdf.header-cell` function allows you to indicate that a cell is a -/// header cell in the following additional situations: -/// -/// - You have a *header column* in which each cell applies to its row. In that -/// case, you pass `{"row"}` as an argument to the -/// @pdf.header-cell.scope[`scope` parameter] to indicate that the header cell -/// applies to the row. -/// - You have a cell in @table.header, for example at the very start, that -/// labels both its row and column. In that case, you pass `{"both"}` as an -/// argument to the @pdf.header-cell.scope[`scope`] parameter. -/// - You have a header cell in a row not containing other header cells. In that -/// case, you can use this function to mark it as a header cell. -/// -/// The API of this feature is temporary. Hence, calling this function requires -/// enabling the `a11y-extras` feature flag at the moment. In a future Typst -/// release, this functionality may move out of the `pdf` module so that tables -/// in other export targets can contain the same information. -/// -/// ```example -/// >>> #set text(font: "IBM Plex Sans") -/// #show table.cell.where(x: 0): set text(weight: "medium") -/// #show table.cell.where(y: 0): set text(weight: "bold") -/// -/// #table( -/// columns: 3, -/// align: (start, end, end), -/// -/// table.header( -/// // Top-left cell: Labels both the nutrient rows -/// // and the serving size columns. -/// pdf.header-cell(scope: "both")[Nutrient], -/// [Per 100g], -/// [Per Serving], -/// ), -/// -/// // First column cells are row headers -/// pdf.header-cell(scope: "row")[Calories], -/// [250 kcal], [375 kcal], -/// pdf.header-cell(scope: "row")[Protein], -/// [8g], [12g], -/// pdf.header-cell(scope: "row")[Fat], -/// [12g], [18g], -/// pdf.header-cell(scope: "row")[Carbs], -/// [30g], [45g], -/// ) -/// ``` -#[func] -pub fn header_cell( - /// The nesting level of this header cell. - #[named] - #[default(NonZeroU32::ONE)] - level: NonZeroU32, - /// What track of the table this header cell applies to. - #[named] - #[default] - scope: TableHeaderScope, - /// The table cell. - /// - /// This can be content or a call to @table.cell. - cell: TableCell, -) -> Content { - cell.with_kind(Smart::Custom(TableCellKind::Header(level, scope))) - .pack() -} - -/// Explicitly defines this cell as a data cell. -/// -/// Each cell in a table is either a header cell or a data cell. By default, all -/// cells in @table.header are header cells, and all other cells data cells. -/// -/// If your header contains a cell that is not a header cell, you can use this -/// function to mark it as a data cell. -/// -/// The API of this feature is temporary. Hence, calling this function requires -/// enabling the `a11y-extras` feature flag at the moment. In a future Typst -/// release, this functionality may move out of the `pdf` module so that tables -/// in other export targets can contain the same information. -/// -/// ```example -/// #show table.cell.where(x: 0): set text(weight: "bold") -/// #show table.cell.where(x: 1): set text(style: "italic") -/// #show table.cell.where(x: 1, y: 0): set text(style: "normal") -/// -/// #table( -/// columns: 3, -/// align: (left, left, center), -/// -/// table.header[Objective][Key Result][Status], -/// -/// table.header( -/// level: 2, -/// table.cell(colspan: 2)[Improve Customer Satisfaction], -/// // Status is data for this objective, not a header -/// pdf.data-cell[✓ On Track], -/// ), -/// [], [Increase NPS to 50+], [45], -/// [], [Reduce churn to \<5%], [4.2%], -/// -/// table.header( -/// level: 2, -/// table.cell(colspan: 2)[Grow Revenue], -/// pdf.data-cell[⚠ At Risk], -/// ), -/// [], [Achieve \$2M ARR], [\$1.8M], -/// [], [Close 50 enterprise deals], [38], -/// ) -/// ``` -#[func] -pub fn data_cell( - /// The table cell. - /// - /// This can be content or a call to @table.cell. - cell: TableCell, -) -> Content { - cell.with_kind(Smart::Custom(TableCellKind::Data)).pack() -} - -#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] -pub enum TableCellKind { - Header(NonZeroU32, TableHeaderScope), - Footer, - #[default] - Data, -} - -/// Which table track a header cell labels. -#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum TableHeaderScope { - /// The header cell refers to both the row and the column. - Both, - /// The header cell refers to the column. - #[default] - Column, - /// The header cell refers to the row. - Row, -} - -impl TableHeaderScope { - pub fn refers_to_column(&self) -> bool { - match self { - TableHeaderScope::Both => true, - TableHeaderScope::Column => true, - TableHeaderScope::Row => false, - } - } - - pub fn refers_to_row(&self) -> bool { - match self { - TableHeaderScope::Both => true, - TableHeaderScope::Column => false, - TableHeaderScope::Row => true, - } - } -} - -/// Used to delimit content for tagged PDF. -#[elem(Construct, Tagged)] -pub struct PdfMarkerTag { - #[internal] - #[required] - pub kind: PdfMarkerTagKind, - #[required] - pub body: Content, -} - -impl Construct for PdfMarkerTag { - fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { - bail!(args.span, "cannot be constructed manually"); - } -} - -macro_rules! pdf_marker_tag { - ($(#[doc = $doc:expr] $variant:ident$(($($name:ident: $ty:ty)+))?,)+) => { - #[derive(Debug, Clone, Eq, PartialEq, Hash)] - pub enum PdfMarkerTagKind { - $( - #[doc = $doc] - $variant $(($($ty),+))? - ),+ - } - - impl PdfMarkerTag { - $( - #[doc = $doc] - #[expect(non_snake_case)] - pub fn $variant($($($name: $ty,)+)? body: Content) -> Content { - let span = body.span(); - Self { - kind: PdfMarkerTagKind::$variant $(($($name),+))?, - body, - }.pack().spanned(span) - } - )+ - } - } -} - -pdf_marker_tag! { - /// `TOC`. - OutlineBody, - /// `L` bibliography list. - Bibliography(numbered: bool), - /// `LBody` wrapping `BibEntry`. - BibEntry, - /// `Lbl` (marker) of the list item. - ListItemLabel, - /// `LBody` of the list item. - ListItemBody, - /// `Lbl` of the term item. - TermsItemLabel, - /// `LBody` the term item including the label. - TermsItemBody, - /// A generic `Lbl`. - Label, -} diff --git a/crates/typst-library/src/pdf/attach.rs b/crates/typst-library/src/pdf/attach.rs deleted file mode 100644 index 08d74f1a3492..000000000000 --- a/crates/typst-library/src/pdf/attach.rs +++ /dev/null @@ -1,86 +0,0 @@ -use ecow::EcoString; -use typst_syntax::Spanned; - -use crate::World; -use crate::diag::At; -use crate::foundations::{Bytes, Cast, Derived, PathOrStr, elem}; - -/// A file that will be attached to the output PDF. -/// -/// This can be used to distribute additional files associated with the PDF -/// within it. PDF readers will display the files in a file listing. -/// -/// Some international standards use this mechanism to attach machine-readable -/// data (e.g., ZUGFeRD/Factur-X for invoices) that mirrors the visual content -/// of the PDF. -/// -/// = Example -/// ```typ -/// #pdf.attach( -/// "experiment.csv", -/// relationship: "supplement", -/// mime-type: "text/csv", -/// description: "Raw Oxygen readings from the Arctic experiment", -/// ) -/// ``` -/// -/// = Notes -/// - This element is ignored if exporting to a format other than PDF. -/// - File attachments are not currently supported for PDF/A-2, even if the -/// attached file conforms to PDF/A-1 or PDF/A-2. -#[elem(keywords = ["embed"], Locatable)] -pub struct AttachElem { - /// The path of the file to be attached. - /// - /// Must always be specified, but is only read from if no data is provided - /// in the following argument. - #[required] - #[parse( - let Spanned { v: path, span } = - args.expect::>("path")?; - let resolved = path.resolve_if_some(span.id()).at(span)?; - // The derived part is the virtual-root-relative resolved path. - let derived = resolved.vpath().get_without_slash().into(); - Derived::new(path, derived) - )] - pub path: Derived, - - /// Raw file data, optionally. - /// - /// If omitted, the data is read from the specified path. - #[positional] - // Not actually required as an argument, but always present as a field. - // We can't distinguish between the two at the moment. - #[required] - #[parse( - match args.eat::()? { - Some(data) => data, - None => engine.world.file(resolved.intern()).at(span)?, - } - )] - pub data: Bytes, - - /// The relationship of the attached file to the document. - /// - /// Ignored if export doesn't target PDF/A-3. - pub relationship: Option, - - /// The MIME type of the attached file. - pub mime_type: Option, - - /// A description for the attached file. - pub description: Option, -} - -/// The relationship of an attached file with the document. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum AttachedFileRelationship { - /// The PDF document was created from the source file. - Source, - /// The file was used to derive a visual presentation in the PDF. - Data, - /// An alternative representation of the document. - Alternative, - /// Additional resources for the document. - Supplement, -} diff --git a/crates/typst-library/src/pdf/mod.rs b/crates/typst-library/src/pdf/mod.rs deleted file mode 100644 index 7dcdb9283ea1..000000000000 --- a/crates/typst-library/src/pdf/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! PDF-specific functionality. - -mod accessibility; -mod attach; - -pub use self::accessibility::*; -pub use self::attach::*; - -use crate::foundations::{Module, Scope}; -use crate::{Feature, Features}; - -/// Hook up all `pdf` definitions. -pub fn module(features: &Features) -> Module { - let mut pdf = Scope::deduplicating(); - pdf.start_category(crate::Category::Pdf); - pdf.define_elem::(); - pdf.define_elem::(); - if features.is_enabled(Feature::A11yExtras) { - pdf.define_func::(); - pdf.define_func::(); - pdf.define_func::(); - } - Module::new("pdf", pdf) -} diff --git a/crates/typst-library/src/routines.rs b/crates/typst-library/src/routines.rs index 63c46f7d9ef7..03c2efad6f70 100644 --- a/crates/typst-library/src/routines.rs +++ b/crates/typst-library/src/routines.rs @@ -7,9 +7,10 @@ use typst_utils::LazyHash; use crate::diag::SourceResult; use crate::engine::{Engine, Route, Sink, Traced}; +use crate::format::DocumentFormatOptions; use crate::foundations::{ - Args, Closure, Content, Context, Func, Module, NativeRuleMap, Scope, StyleChain, - Styles, Value, + Args, Closure, Content, Context, Func, NativeRuleMap, Scope, StyleChain, Styles, + Value, }; use crate::introspection::{Introspector, Locator, SplitLocator}; use crate::layout::{Frame, Region}; @@ -97,9 +98,6 @@ routines! { region: Region, ) -> SourceResult - /// Constructs the `html` module. - fn html_module() -> Module - /// Returns the body of a MathML `HtmlElem`, if the content is one. fn html_mathml_body<'a>( content: &'a Content, @@ -157,7 +155,7 @@ pub enum RealizationKind<'a> { Bundle, /// This the root realization for a document. Requires a mutable reference /// to document metadata that will be filled from `set document` rules. - Document { info: &'a mut DocumentInfo }, + Document { info: &'a mut DocumentInfo, options: &'a mut DocumentFormatOptions }, /// A nested realization in a container (e.g. a `block` or an `html.div`). /// Requires a mutable reference to an enum that will be set to /// `FragmentKind::Inline` if the fragment's content was fully inline. diff --git a/crates/typst-library/src/symbols.rs b/crates/typst-library/src/symbols.rs index 3cb0c15ee66b..ca5ea7d1ed43 100644 --- a/crates/typst-library/src/symbols.rs +++ b/crates/typst-library/src/symbols.rs @@ -23,7 +23,7 @@ fn extend_scope_from_codex_module(scope: &mut Scope, module: codex::Module) { let scope_binding = scope.define(name, value); if let Some(message) = binding.deprecation { - scope_binding.deprecated(Deprecation::new().with_message(message)); + scope_binding.with_deprecation(Deprecation::new().with_message(message)); } } } diff --git a/crates/typst-library/src/text/case.rs b/crates/typst-library/src/text/case.rs index c6f6ec72d165..3a334e097fb8 100644 --- a/crates/typst-library/src/text/case.rs +++ b/crates/typst-library/src/text/case.rs @@ -9,7 +9,7 @@ use crate::text::TextElem; /// #lower[*My Text*] \ /// #lower[already low] /// ``` -#[func(title = "Lowercase")] +#[func(title = "Lowercase", since = "forever")] pub fn lower( /// The text to convert to lowercase. text: Caseable, @@ -25,7 +25,7 @@ pub fn lower( /// #upper[*my text*] \ /// #upper[ALREADY HIGH] /// ``` -#[func(title = "Uppercase")] +#[func(title = "Uppercase", since = "forever")] pub fn upper( /// The text to convert to uppercase. text: Caseable, diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index dcf644a2362f..2b8743200857 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -9,7 +9,7 @@ use crate::visualize::{Color, FixedStroke, Paint, Stroke}; /// ```example /// This is #underline[important]. /// ``` -#[elem(Locatable, Tagged)] +#[elem(since = "forever", Locatable, Tagged)] pub struct UnderlineElem { /// How to @stroke[stroke] the line. /// @@ -59,9 +59,9 @@ pub struct UnderlineElem { /// Whether the line is placed behind the content it underlines. /// /// ```example - /// #set underline(stroke: (thickness: 1em, paint: maroon, cap: "round")) - /// #underline(background: true)[This is stylized.] \ - /// #underline(background: false)[This is partially hidden.] + /// #set underline(stroke: aqua + 5pt, evade: false) + /// #underline(background: true)[Fully visible.] \ + /// #underline(background: false)[Partially hidden.] /// ``` #[default(false)] pub background: bool, @@ -77,7 +77,7 @@ pub struct UnderlineElem { /// ```example /// #overline[A line over text.] /// ``` -#[elem(Locatable, Tagged)] +#[elem(since = "forever", Locatable, Tagged)] pub struct OverlineElem { /// How to @stroke[stroke] the line. /// @@ -133,9 +133,9 @@ pub struct OverlineElem { /// Whether the line is placed behind the content it overlines. /// /// ```example - /// #set overline(stroke: (thickness: 1em, paint: maroon, cap: "round")) - /// #overline(background: true)[This is stylized.] \ - /// #overline(background: false)[This is partially hidden.] + /// #set overline(stroke: aqua + 5pt) + /// #overline(background: true)[Fully visible.] \ + /// #overline(background: false)[Partially hidden.] /// ``` #[default(false)] pub background: bool, @@ -151,7 +151,7 @@ pub struct OverlineElem { /// ```example /// This is #strike[not] relevant. /// ``` -#[elem(title = "Strikethrough", Locatable, Tagged)] +#[elem(title = "Strikethrough", since = "forever", Locatable, Tagged)] pub struct StrikeElem { /// How to @stroke[stroke] the line. /// @@ -192,7 +192,7 @@ pub struct StrikeElem { /// Whether the line is placed behind the content. /// /// ```example - /// #set strike(stroke: red) + /// #set strike(stroke: red + 2pt) /// #strike(background: true)[This is behind.] \ /// #strike(background: false)[This is in front.] /// ``` @@ -210,7 +210,7 @@ pub struct StrikeElem { /// ```example /// This is #highlight[important]. /// ``` -#[elem(Locatable, Tagged)] +#[elem(since = "0.8.0", Locatable, Tagged)] pub struct HighlightElem { /// The color to highlight the text with. /// diff --git a/crates/typst-library/src/text/font/exceptions.rs b/crates/typst-library/src/text/font/exceptions.rs index c02f616af612..2f625eb7a6a9 100644 --- a/crates/typst-library/src/text/font/exceptions.rs +++ b/crates/typst-library/src/text/font/exceptions.rs @@ -65,6 +65,109 @@ static EXCEPTION_MAP: phf::Map<&'static str, Exception> = phf::phf_map! { .weight(700), "FandolSong-Bold" => Exception::new() .weight(700), + // IBM Plex + "IBMPlexMono-Medm" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-MedmItalic" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-SmBld" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-SmBldItalic" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-Text" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-TextItalic" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexSans-Medm" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-MedmItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-SmBld" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-SmBldItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-Text" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-TextItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansArabic-Medium" => Exception::new() + .family("IBM Plex Sans Arabic"), + "IBMPlexSansArabic-SemiBold" => Exception::new() + .family("IBM Plex Sans Arabic"), + "IBMPlexSansArabic-Text" => Exception::new() + .family("IBM Plex Sans Arabic"), + "IBMPlexSansCond-Medm" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-MedmItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-SmBld" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-SmBldItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-Text" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-TextItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansDevanagari-Medm" => Exception::new() + .family("IBM Plex Sans Devanagari"), + "IBMPlexSansDevanagari-SmBld" => Exception::new() + .family("IBM Plex Sans Devanagari"), + "IBMPlexSansDevanagari-Text" => Exception::new() + .family("IBM Plex Sans Devanagari"), + "IBMPlexSansHebrew-Medm" => Exception::new() + .family("IBM Plex Sans Hebrew"), + "IBMPlexSansHebrew-SmBld" => Exception::new() + .family("IBM Plex Sans Hebrew"), + "IBMPlexSansHebrew-Text" => Exception::new() + .family("IBM Plex Sans Hebrew"), + "IBMPlexSansJP-Medm" => Exception::new() + .family("IBM Plex Sans JP"), + "IBMPlexSansJP-SmBld" => Exception::new() + .family("IBM Plex Sans JP"), + "IBMPlexSansJP-Text" => Exception::new() + .family("IBM Plex Sans JP"), + "IBMPlexSansKR-Medm" => Exception::new() + .family("IBM Plex Sans KR"), + "IBMPlexSansKR-SmBld" => Exception::new() + .family("IBM Plex Sans KR"), + "IBMPlexSansKR-Text" => Exception::new() + .family("IBM Plex Sans KR"), + "IBMPlexSansSC-Medm" => Exception::new() + .family("IBM Plex Sans SC"), + "IBMPlexSansSC-SmBld" => Exception::new() + .family("IBM Plex Sans SC"), + "IBMPlexSansSC-Text" => Exception::new() + .family("IBM Plex Sans SC"), + "IBMPlexSansTC-Medm" => Exception::new() + .family("IBM Plex Sans TC"), + "IBMPlexSansTC-SmBld" => Exception::new() + .family("IBM Plex Sans TC"), + "IBMPlexSansTC-Text" => Exception::new() + .family("IBM Plex Sans TC"), + "IBMPlexSansThai-Medm" => Exception::new() + .family("IBM Plex Sans Thai"), + "IBMPlexSansThai-SmBld" => Exception::new() + .family("IBM Plex Sans Thai"), + "IBMPlexSansThai-Text" => Exception::new() + .family("IBM Plex Sans Thai"), + "IBMPlexSansThaiLooped-Medm" => Exception::new() + .family("IBM Plex Sans Thai Looped"), + "IBMPlexSansThaiLooped-SmBld" => Exception::new() + .family("IBM Plex Sans Thai Looped"), + "IBMPlexSansThaiLooped-Text" => Exception::new() + .family("IBM Plex Sans Thai Looped"), + "IBMPlexSerif-Medium" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-MediumItalic" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-SemiBold" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-SemiBoldItalic" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-Text" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-TextItalic" => Exception::new() + .family("IBM Plex Serif"), // Noto fonts "NotoNaskhArabicUISemi-Bold" => Exception::new() .family("Noto Naskh Arabic UI") diff --git a/crates/typst-library/src/text/font/info.rs b/crates/typst-library/src/text/font/info.rs index 4239d3b13bbf..c0f32ab59133 100644 --- a/crates/typst-library/src/text/font/info.rs +++ b/crates/typst-library/src/text/font/info.rs @@ -289,7 +289,12 @@ pub struct Coverage(Vec); impl Coverage { /// Encode a vector of codepoints. pub fn from_vec(mut codepoints: Vec) -> Self { - codepoints.sort_unstable(); + #[expect( + clippy::stable_sort_primitive, + reason = "`sort_unstable` hurts performance here. Compiling a 'Hello World!' \ + PDF with `--ignore-system-fonts` goes from 7.8ms to 9.1ms or +16%." + )] + codepoints.sort(); codepoints.dedup(); let mut runs = Vec::new(); diff --git a/crates/typst-library/src/text/font/metrics.rs b/crates/typst-library/src/text/font/metrics.rs index 017c05ff3923..9b8bf6bebfd2 100644 --- a/crates/typst-library/src/text/font/metrics.rs +++ b/crates/typst-library/src/text/font/metrics.rs @@ -167,6 +167,10 @@ pub struct MathConstants { pub stack_bottom_display_style_shift_down: Em, pub stack_gap_min: Em, pub stack_display_style_gap_min: Em, + pub stretch_stack_top_shift_up: Em, + pub stretch_stack_bottom_shift_down: Em, + pub stretch_stack_gap_above_min: Em, + pub stretch_stack_gap_below_min: Em, pub fraction_numerator_shift_up: Em, pub fraction_numerator_display_style_shift_up: Em, pub fraction_denominator_shift_down: Em, @@ -262,6 +266,14 @@ impl MathConstants { stack_gap_min: font.to_em(constants.stack_gap_min().value), stack_display_style_gap_min: font .to_em(constants.stack_display_style_gap_min().value), + stretch_stack_top_shift_up: font + .to_em(constants.stretch_stack_top_shift_up().value), + stretch_stack_bottom_shift_down: font + .to_em(constants.stretch_stack_bottom_shift_down().value), + stretch_stack_gap_above_min: font + .to_em(constants.stretch_stack_gap_above_min().value), + stretch_stack_gap_below_min: font + .to_em(constants.stretch_stack_gap_below_min().value), fraction_numerator_shift_up: font .to_em(constants.fraction_numerator_shift_up().value), fraction_numerator_display_style_shift_up: font @@ -358,6 +370,10 @@ impl MathConstants { stack_bottom_display_style_shift_down: Em::zero(), stack_gap_min: 3.0 * metrics.underline.thickness, stack_display_style_gap_min: 7.0 * metrics.underline.thickness, + stretch_stack_top_shift_up: Em::zero(), + stretch_stack_bottom_shift_down: Em::zero(), + stretch_stack_gap_above_min: Em::zero(), + stretch_stack_gap_below_min: Em::zero(), fraction_numerator_shift_up: Em::zero(), fraction_numerator_display_style_shift_up: Em::zero(), fraction_denominator_shift_down: Em::zero(), diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs index b2eb5c5ebfd4..659f94270a14 100644 --- a/crates/typst-library/src/text/lang.rs +++ b/crates/typst-library/src/text/lang.rs @@ -113,6 +113,7 @@ const TRANSLATIONS: &[(&str, &str)] = &[ translation!("uk"), translation!("ur"), translation!("vi"), + translation!("za"), translation!("zh"), translation!("zh-TW"), ]; @@ -706,7 +707,7 @@ mod tests { #[test] fn test_region_option_eq() { - let region = Some(Region([b'U', b'S'])); + let region = Some(Region(*b"US")); assert!(option_eq(region, "US")); assert!(!option_eq(region, "AB")); } diff --git a/crates/typst-library/src/text/linebreak.rs b/crates/typst-library/src/text/linebreak.rs index a299ef3b6df0..157af8b18d16 100644 --- a/crates/typst-library/src/text/linebreak.rs +++ b/crates/typst-library/src/text/linebreak.rs @@ -19,7 +19,7 @@ use crate::foundations::{Content, NativeElement, elem}; /// This function also has dedicated syntax: To insert a line break, simply /// write a backslash followed by whitespace. This always creates an unjustified /// break. -#[elem(title = "Line Break")] +#[elem(title = "Line Break", since = "forever")] pub struct LinebreakElem { /// Whether to justify the line before the break. /// diff --git a/crates/typst-library/src/text/lorem.rs b/crates/typst-library/src/text/lorem.rs index fc89b7c4bfb6..4ba4dd5275ce 100644 --- a/crates/typst-library/src/text/lorem.rs +++ b/crates/typst-library/src/text/lorem.rs @@ -20,7 +20,7 @@ use crate::foundations::{Str, func}; /// = More Blind Text /// #lorem(15) /// ``` -#[func(keywords = ["Blind Text"])] +#[func(since = "forever", keywords = ["Blind Text"])] pub fn lorem( /// The length of the blind text in words. words: usize, diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index 43507f68a7e3..4b9cf91f414b 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -87,7 +87,7 @@ pub(super) fn define(global: &mut Scope) { /// With a function call. /// ]) /// ``` -#[elem(Debug, Construct, PlainText, Repr)] +#[elem(since = "forever", Debug, Construct, PlainText, Repr)] pub struct TextElem { /// A font family descriptor or priority list of font family descriptors. /// @@ -707,10 +707,10 @@ pub struct TextElem { /// ```example /// #set text(font: "Noto Sans", 20pt) /// #set text(number-type: "lining") - /// Number 9. + /// Lining: 0123456789. /// /// #set text(number-type: "old-style") - /// Number 9. + /// Old style: 0123456789. /// ``` #[ghost] pub number_type: Smart, diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index f952e57aeb04..88018f622adb 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -4,6 +4,8 @@ use std::sync::{Arc, LazyLock}; use comemo::Tracked; use ecow::{EcoString, EcoVec}; +use icu_collator::options::CollatorOptions; +use icu_collator::{Collator, CollatorPreferences}; use syntect::highlighting as synt; use syntect::parsing::{ParseSyntaxError, SyntaxDefinition, SyntaxSet, SyntaxSetBuilder}; use typst_syntax::{LinkedNode, Span, Spanned, split_newlines}; @@ -230,7 +232,7 @@ use crate::visualize::Color; /// first character not valid for an identifier will be interpreted as starting /// the raw text. /// -/// For example, in the current verion of Typst, if a raw block starts with +/// For example, in the current version of Typst, if a raw block starts with /// `C++`, the identifier `C` will be the language tag, and the raw text will /// start with `++`. If a raw block starts with `++C`, it will have no language /// tag and the raw text will start with `++C`. @@ -250,6 +252,7 @@ use crate::visualize::Color; #[elem( scope, title = "Raw Text / Code", + since = "forever", Synthesize, Locatable, Tagged, @@ -331,6 +334,23 @@ pub struct RawElem { /// @reference:syntax:code[Typst code], and /// @reference:syntax:math[Typst math], respectively. /// + /// #folding-details( + /// title: [Available syntaxes], + /// docs-table( + /// table.header[Name][Tags], + /// ..stdx.raw-langs.map(((name, tokens)) => ( + /// name, + /// // Some tokens start with `.`. For example, `.env`. The Unicode + /// // Line Breaking Algorithm forbids breaking before `.`, even after + /// // spaces. Therefore, we add zero-width spaces to provide + /// // additional break opportunities. Note that + /// // https://github.com/typst/typst/issues/5317 might change the + /// // situation. + /// tokens.map(raw).join[, #sym.zws], + /// )).flatten() + /// ) + /// ) + /// /// ````example /// ```typ /// This is *Typst!* @@ -371,6 +391,9 @@ pub struct RawElem { /// - Raw bytes from which the syntax should be decoded. /// - An array where each item is one of the above. /// + /// For a list of built-in syntaxes, see the documentation of the + /// @raw.lang[`lang` parameter]. + /// /// ````example /// #set raw(syntaxes: "SExpressions.sublime-syntax") /// @@ -462,21 +485,29 @@ impl RawElem { impl RawElem { /// The supported language names and tags. pub fn languages() -> Vec<(&'static str, Vec<&'static str>)> { - RAW_SYNTAXES + let typst = [ + ("Typst", vec!["typ", "typst"]), + ("Typst (code)", vec!["typc"]), + ("Typst (math)", vec!["typm"]), + ]; + + let other = RAW_SYNTAXES .syntaxes() .iter() + .filter(|syntax| !syntax.file_extensions.is_empty()) .map(|syntax| { - ( - syntax.name.as_str(), - syntax.file_extensions.iter().map(|s| s.as_str()).collect(), - ) - }) - .chain([ - ("Typst", vec!["typ"]), - ("Typst (code)", vec!["typc"]), - ("Typst (math)", vec!["typm"]), - ]) - .collect() + let name = syntax.name.as_str(); + let exts = syntax.file_extensions.iter().map(|s| s.as_str()).collect(); + (name, exts) + }); + + let front = typst.len(); + let mut syntaxes: Vec<_> = typst.into_iter().chain(other).collect(); + let collator = + Collator::try_new(CollatorPreferences::default(), CollatorOptions::default()) + .unwrap(); + syntaxes[front..].sort_by(|&(a, _), &(b, _)| collator.compare(a, b)); + syntaxes } } @@ -809,7 +840,7 @@ fn format_theme_error(error: syntect::LoadingError) -> LoadError { /// It allows you to access various properties of the line, such as the line /// number, the raw non-highlighted text, the highlighted text, and whether it /// is the first or last line of the raw block. -#[elem(name = "line", title = "Raw Text / Code Line", Tagged, PlainText)] +#[elem(name = "line", title = "Raw Text / Code Line", since = "0.9.0", Tagged, PlainText)] pub struct RawLine { /// The line number of the raw line inside of the raw block, starts at 1. #[required] diff --git a/crates/typst-library/src/text/shift.rs b/crates/typst-library/src/text/shift.rs index 08d2be5d362c..9b136c720695 100644 --- a/crates/typst-library/src/text/shift.rs +++ b/crates/typst-library/src/text/shift.rs @@ -12,7 +12,7 @@ use crate::text::{FontMetrics, ScriptMetrics, TextSize}; /// ```example /// Revenue#sub[yearly] /// ``` -#[elem(title = "Subscript", Tagged)] +#[elem(title = "Subscript", since = "forever", Tagged)] pub struct SubElem { /// Whether to use subscript glyphs from the font if available. /// @@ -71,7 +71,7 @@ pub struct SubElem { /// ```example /// 1#super[st] try! /// ``` -#[elem(title = "Superscript", Tagged)] +#[elem(title = "Superscript", since = "forever", Tagged)] pub struct SuperElem { /// Whether to use superscript glyphs from the font if available. /// diff --git a/crates/typst-library/src/text/smallcaps.rs b/crates/typst-library/src/text/smallcaps.rs index 9aaa118e134d..8284391d1e9c 100644 --- a/crates/typst-library/src/text/smallcaps.rs +++ b/crates/typst-library/src/text/smallcaps.rs @@ -40,7 +40,7 @@ use crate::foundations::{Content, elem}; /// = Introduction /// #lorem(40) /// ``` -#[elem(title = "Small Capitals")] +#[elem(title = "Small Capitals", since = "forever")] pub struct SmallcapsElem { /// Whether to turn uppercase letters into small capitals as well. /// diff --git a/crates/typst-library/src/text/smartquote.rs b/crates/typst-library/src/text/smartquote.rs index 04796b767144..f58b4632f440 100644 --- a/crates/typst-library/src/text/smartquote.rs +++ b/crates/typst-library/src/text/smartquote.rs @@ -29,7 +29,7 @@ use crate::text::{Lang, Region, TextElem}; /// = Syntax /// This function also has dedicated syntax: The normal quote characters (`'` /// and `"`). Typst automatically makes your quotes smart. -#[elem(name = "smartquote", PlainText)] +#[elem(name = "smartquote", since = "forever", PlainText)] pub struct SmartQuoteElem { /// Whether this should be a double quote. #[default(true)] diff --git a/crates/typst-library/src/text/space.rs b/crates/typst-library/src/text/space.rs index 143046fac8f7..8a673ffdb8f8 100644 --- a/crates/typst-library/src/text/space.rs +++ b/crates/typst-library/src/text/space.rs @@ -6,13 +6,30 @@ use crate::foundations::{ }; /// A text space. -#[elem(Unlabellable, PlainText, Repr)] -pub struct SpaceElem {} +#[elem(Unlabellable, PlainText, Repr, PartialEq)] +pub struct SpaceElem { + #[required] + #[internal] + #[parse(false)] + pub had_newline: bool, +} + +impl PartialEq for SpaceElem { + fn eq(&self, _other: &Self) -> bool { + // Note: This is fine for comemo because it only compares based on hash. + true + } +} impl SpaceElem { /// Get the globally shared space element. pub fn shared() -> &'static Content { - singleton!(Content, SpaceElem::new().pack()) + singleton!(Content, SpaceElem::new(false).pack()) + } + + /// A globally shared space element with `had_newline` set to true. + pub fn shared_with_newline() -> &'static Content { + singleton!(Content, SpaceElem::new(true).pack()) } } diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs index 712f51012792..091f6b3fe9c1 100644 --- a/crates/typst-library/src/visualize/color.rs +++ b/crates/typst-library/src/visualize/color.rs @@ -277,7 +277,7 @@ static TO_SRGB: LazyLock> = LazyLock::new(|| /// ) /// })) /// ``` -#[ty(scope, cast)] +#[ty(scope, cast, since = "forever")] #[derive(Clone, PartialEq, Eq, Hash)] pub enum Color { /// A process color. @@ -326,8 +326,8 @@ impl Color { /// Create a grayscale color. /// - /// A grayscale color is represented internally by a single `lightness` - /// component. + /// A grayscale color is represented by `lightness` (@ratio) and `alpha` + /// (@ratio) components. /// /// These components are also available using the /// @color.components[`components`] method. @@ -337,7 +337,7 @@ impl Color { /// box(square(fill: luma(x))) /// } /// ``` - #[func] + #[func(since = "forever")] pub fn luma( args: &mut Args, /// The lightness component. @@ -345,10 +345,10 @@ impl Color { lightness: Component, /// The alpha component. #[external] - alpha: RatioComponent, + alpha: Component, /// Alternatively: The color to convert to grayscale. /// - /// If this is given, the `lightness` should not be given. + /// If this is given, the individual components should not be given. #[external] color: Color, ) -> SourceResult { @@ -356,11 +356,20 @@ impl Color { if let Some(color) = args.find::()? { color.to_luma() } else { - let Component(gray) = - args.expect("gray component").unwrap_or(Component(Ratio::one())); - let RatioComponent(alpha) = - args.eat()?.unwrap_or(RatioComponent(Ratio::one())); - Luma::new(gray.get() as f32, alpha.get() as f32) + let missing_lightness = args.is_empty(); + let Component(l) = + args.expect("lightness component").map_err(|mut err| { + if missing_lightness { + // Before 0.16, lightness accidentally defaulted to + // `100%`, so this should help with breakage. I + // could not find a cleaner way to add the hint >:( + let last = err.make_mut().last_mut().unwrap(); + last.hint("try writing `white` or `luma(100%)`"); + } + err + })?; + let Component(alpha) = args.eat()?.unwrap_or(Component(Ratio::one())); + Luma::new(l.get() as f32, alpha.get() as f32) }, ))) } @@ -390,7 +399,7 @@ impl Color { /// fill: oklab(27%, 20%, -3%, 50%) /// ) /// ``` - #[func] + #[func(since = "0.9.0")] pub fn oklab( args: &mut Args, /// The lightness component. @@ -449,7 +458,7 @@ impl Color { /// fill: oklch(40%, 0.2, 160deg, 50%) /// ) /// ``` - #[func] + #[func(since = "0.10.0")] pub fn oklch( args: &mut Args, /// The lightness component. @@ -511,7 +520,7 @@ impl Color { /// 30%, 50%, 10%, /// )) /// ``` - #[func(title = "Linear RGB")] + #[func(title = "Linear RGB", since = "0.9.0")] pub fn linear_rgb( args: &mut Args, /// The red component. @@ -569,7 +578,7 @@ impl Color { /// #square(fill: rgb(87, 127, 230)) /// #square(fill: rgb(25%, 13%, 65%)) /// ``` - #[func(title = "RGB")] + #[func(title = "RGB", since = "forever")] pub fn rgb( args: &mut Args, /// The red component. @@ -645,7 +654,7 @@ impl Color { /// fill: cmyk(27%, 0%, 3%, 5%) /// ) /// ``` - #[func(title = "CMYK")] + #[func(title = "CMYK", since = "forever")] pub fn cmyk( args: &mut Args, /// The cyan component. @@ -699,7 +708,7 @@ impl Color { /// fill: color.hsl(30deg, 50%, 60%) /// ) /// ``` - #[func(title = "HSL")] + #[func(title = "HSL", since = "0.9.0")] pub fn hsl( args: &mut Args, /// The hue angle. @@ -756,7 +765,7 @@ impl Color { /// fill: color.hsv(30deg, 50%, 60%) /// ) /// ``` - #[func(title = "HSV")] + #[func(title = "HSV", since = "0.9.0")] pub fn hsv( args: &mut Args, /// The hue angle. @@ -867,7 +876,7 @@ impl Color { /// // note that the alpha component is included by default /// #rgb(40%, 60%, 80%).components() /// ``` - #[func] + #[func(since = "0.9.0")] pub fn components( &self, /// Whether to include the alpha component. @@ -897,7 +906,7 @@ impl Color { /// #let color = cmyk(1%, 2%, 3%, 4%) /// #(color.space() == cmyk) /// ``` - #[func] + #[func(since = "0.9.0")] pub fn space(&self) -> ColorSpace { match self { Self::Process(c) => c.space().into(), @@ -908,7 +917,7 @@ impl Color { /// Returns the color's RGB(A) hex representation (such as `#ffaa32` or /// `#020304fe`). The alpha component (last two digits in `#020304fe`) is /// omitted if it is equal to `ff` (255 / 100%). - #[func] + #[func(since = "0.9.0")] pub fn to_hex(&self) -> EcoString { match self { Self::Process(c) => c.to_hex(), @@ -917,7 +926,7 @@ impl Color { } /// Lightens a color by a given factor. - #[func] + #[func(since = "forever")] pub fn lighten( &self, /// The factor to lighten the color by. @@ -930,7 +939,7 @@ impl Color { } /// Darkens a color by a given factor. - #[func] + #[func(since = "forever")] pub fn darken( &self, /// The factor to darken the color by. @@ -946,7 +955,7 @@ impl Color { /// /// Only process colors can be saturated. If you want to saturate a spot /// color, convert it into a process color first. - #[func] + #[func(since = "0.9.0")] pub fn saturate( &self, span: Span, @@ -967,7 +976,7 @@ impl Color { /// /// Only process colors can be desaturated. If you want to desaturate a spot /// color, convert it into a process color first. - #[func] + #[func(since = "0.9.0")] pub fn desaturate( &self, span: Span, @@ -992,7 +1001,7 @@ impl Color { /// #square(fill: yellow.negate()) /// #square(fill: yellow.negate(space: rgb)) /// ``` - #[func] + #[func(since = "forever")] pub fn negate( &self, /// The color space used for the transformation. By default, a @@ -1017,7 +1026,7 @@ impl Color { /// /// This function only works on color models with a well-defined hue /// component, i.e. Oklch, HSL, and HSV. - #[func] + #[func(since = "0.9.0")] pub fn rotate( &self, span: Span, @@ -1068,7 +1077,7 @@ impl Color { /// #block(fill: color.mix(red, blue, white)) /// #block(fill: color.mix((red, 70%), (blue, 30%))) /// ``` - #[func] + #[func(since = "0.7.0")] pub fn mix( /// The colors, optionally with weights, specified as a pair (array of /// length two) of color and weight (float or ratio). @@ -1100,7 +1109,7 @@ impl Color { /// #block(fill: red.transparentize(50%))[half red] /// #block(fill: red.transparentize(75%))[quarter red] /// ``` - #[func] + #[func(since = "0.11.0")] pub fn transparentize( &self, /// The factor to change the alpha value by. @@ -1121,7 +1130,7 @@ impl Color { /// #block(fill: half-red.opacify(50%))[three quarters red] /// #block(fill: half-red.opacify(-50%))[one quarter red] /// ``` - #[func] + #[func(since = "0.11.0")] pub fn opacify( &self, /// The scale to change the alpha value by. @@ -2300,7 +2309,7 @@ impl Repr for SpotColor { /// environment. Once you have created a spot colorant, you can create /// colors using its @color.spot.tint[`tint` method]. #[derive(Debug, Clone, Eq, PartialEq, Hash)] -#[ty(scope, name = "spot", title = "Spot Colorant")] +#[ty(scope, name = "spot", title = "Spot Colorant", since = "0.15.0")] pub struct SpotColorant { /// Name of the spot colorant to use, if any. pub name: Option, @@ -2317,7 +2326,7 @@ impl Repr for SpotColorant { #[scope] impl SpotColorant { /// Create a new spot colorant. - #[func(constructor)] + #[func(constructor, since = "0.15.0")] pub fn construct( /// Name of the spot colorant to use. /// @@ -2371,7 +2380,7 @@ impl SpotColorant { /// #square(fill: pantone.tint(70%)) /// #square(fill: pantone.tint(40%)) /// ``` - #[func] + #[func(since = "0.15.0")] pub fn tint( &self, /// The tint percentage, between `{0%}` and `{100%}`. diff --git a/crates/typst-library/src/visualize/curve.rs b/crates/typst-library/src/visualize/curve.rs index b75dc6f11562..b87cbbe5cb44 100644 --- a/crates/typst-library/src/visualize/curve.rs +++ b/crates/typst-library/src/visualize/curve.rs @@ -39,7 +39,7 @@ use super::FixedStroke; /// curve.close(), /// ) /// ``` -#[elem(scope)] +#[elem(scope, since = "0.13.0")] pub struct CurveElem { /// How to fill the curve. /// @@ -170,7 +170,7 @@ impl TryFrom for CurveComponent { /// curve.close(), /// ) /// ``` -#[elem(name = "move", title = "Curve Move")] +#[elem(name = "move", title = "Curve Move", since = "0.13.0")] pub struct CurveMove { /// The starting point for the new component. #[required] @@ -193,7 +193,7 @@ pub struct CurveMove { /// curve.line((150pt, 0pt)), /// ) /// ``` -#[elem(name = "line", title = "Curve Line")] +#[elem(name = "line", title = "Curve Line", since = "0.13.0")] pub struct CurveLine { /// The point at which the line shall end. #[required] @@ -233,7 +233,7 @@ pub struct CurveLine { /// curve.quad((20pt, 20pt), (100pt, 0pt)), /// ) /// ``` -#[elem(name = "quad", title = "Curve Quadratic Segment")] +#[elem(name = "quad", title = "Curve Quadratic Segment", since = "0.13.0")] pub struct CurveQuad { /// The control point of the quadratic Bézier curve. /// @@ -280,7 +280,7 @@ pub struct CurveQuad { /// curve.cubic((10pt, 20pt), (90pt, 60pt), (100pt, 0pt)), /// ) /// ``` -#[elem(name = "cubic", title = "Curve Cubic Segment")] +#[elem(name = "cubic", title = "Curve Cubic Segment", since = "0.13.0")] pub struct CurveCubic { /// The control point going out from the start of the curve segment. /// @@ -362,7 +362,7 @@ pub struct CurveCubic { /// #shape(mode: "smooth") /// #shape(mode: "straight") /// ``` -#[elem(name = "close", title = "Curve Close")] +#[elem(name = "close", title = "Curve Close", since = "0.13.0")] pub struct CurveClose { /// How to close the curve. pub mode: CloseMode, diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs index f5b9eb7f8c3d..388ffa664ba0 100644 --- a/crates/typst-library/src/visualize/gradient.rs +++ b/crates/typst-library/src/visualize/gradient.rs @@ -67,9 +67,55 @@ use crate::visualize::{ /// and an offset. The offset is a @ratio[ratio] between `{0%}` and `{100%}` or /// an angle between `{0deg}` and `{360deg}`. The offset is a relative position /// that determines how far along the gradient the stop is located. The stop's -/// color is the color of the gradient at that position. You can choose to omit -/// the offsets when defining a gradient. In this case, Typst will space all -/// stops evenly. +/// color defines the color the gradient should have at that position. +/// +/// Each stop is passed as a positional argument and can take one of two forms: +/// - Just a color, like `{red}`, to let Typst place the stop automatically. +/// - An array of a color and its offset, like `{(red, 30%)}`, to place the stop +/// yourself. +/// +/// You can choose to omit the offsets when defining a gradient. In this case, +/// Typst will space all stops evenly. Otherwise, offsets must be provided for +/// _every_ stop. They must not decrease from one stop to the next, the first +/// must be `{0%}`, and the last must be `{100%}`. +/// +/// ```example +/// #rect( +/// width: 100%, +/// fill: gradient.linear( +/// (green, 0%), +/// (red, 30%), +/// (blue, 100%), +/// ), +/// ) +/// ``` +/// +/// Giving two stops the same offset creates a hard edge instead of a smooth +/// transition: +/// +/// ```example +/// #rect( +/// width: 100%, +/// fill: gradient.linear( +/// (orange, 0%), +/// (orange, 50%), +/// (blue, 50%), +/// (blue, 100%), +/// ), +/// ) +/// ``` +/// +/// Typst provides the @gradient.sharp[`sharp`] method on gradients to +/// automatically turn a smooth gradient into one with hard edges. The +/// example above can equivalently be written as: +/// +/// ```example +/// #rect( +/// width: 100%, +/// fill: gradient.linear(orange, blue) +/// .sharp(2), +/// ) +/// ``` /// /// Typst predefines color maps that you can use as stops. See the /// @color:predefined-color-maps[`color`] documentation for more details. @@ -186,7 +232,7 @@ use crate::visualize::{ /// @color.linear-rgb color spaces. This avoids needing to encode these color /// spaces in your PDF file, but it does add extra stops to your gradient, which /// can increase the file size. -#[ty(scope, cast)] +#[ty(scope, cast, since = "0.9.0")] #[derive(Clone, Eq, PartialEq, Hash)] pub enum Gradient { Linear(Arc), @@ -209,11 +255,21 @@ impl Gradient { /// ), /// ) /// ``` - #[func(title = "Linear Gradient")] + #[func(title = "Linear Gradient", since = "0.9.0")] pub fn linear( args: &mut Args, span: Span, /// The color @gradient:stops[stops] of the gradient. + /// + /// Each stop is given as a separate positional argument. It can either + /// be a color, like `{red}`, or an array of a color and its offset, + /// like `{(red, 30%)}`. If offsets are given, they must be provided for + /// all stops, not decrease from one stop to the next, start at `{0%}`, + /// and end at `{100%}`. + /// + /// When using a predefined color map from the `color.map` module, the + /// @arguments:spreading[spreading operator] `..` is used to pass each + /// stop as a separate argument. #[variadic] stops: Vec>, /// The color space in which to interpolate the gradient. @@ -299,10 +355,13 @@ impl Gradient { /// )), /// ) /// ``` - #[func(title = "Radial Gradient")] + #[func(title = "Radial Gradient", since = "0.9.0")] fn radial( span: Span, /// The color @gradient:stops[stops] of the gradient. + /// + /// Also see the @gradient.linear.stops[linear gradient's documentation] + /// for more details. #[variadic] stops: Vec>, /// The color space in which to interpolate the gradient. @@ -416,10 +475,13 @@ impl Gradient { /// )), /// ) /// ``` - #[func(title = "Conic Gradient")] + #[func(title = "Conic Gradient", since = "0.9.0")] pub fn conic( span: Span, /// The color @gradient:stops[stops] of the gradient. + /// + /// Also see the @gradient.linear.stops[linear gradient's documentation] + /// for more details. #[variadic] stops: Vec>, /// The angle of the gradient. @@ -480,7 +542,7 @@ impl Gradient { /// #rect(fill: grad.sharp(5)) /// #rect(fill: grad.sharp(5, smoothness: 20%)) /// ``` - #[func] + #[func(since = "0.9.0")] pub fn sharp( &self, /// The number of stops in the gradient. @@ -575,7 +637,7 @@ impl Gradient { /// .repeat(4), /// ) /// ``` - #[func] + #[func(since = "0.9.0")] pub fn repeat( &self, /// The number of times to repeat the gradient. @@ -655,7 +717,7 @@ impl Gradient { } /// Returns the kind of this gradient. - #[func] + #[func(since = "0.9.0")] pub fn kind(&self) -> Func { match self { Self::Linear(_) => Self::linear_data().into(), @@ -665,7 +727,7 @@ impl Gradient { } /// Returns the stops of this gradient. - #[func] + #[func(since = "0.9.0")] pub fn stops(&self) -> Vec { match self { Self::Linear(linear) => linear @@ -696,7 +758,7 @@ impl Gradient { } /// Returns the mixing space of this gradient. - #[func] + #[func(since = "0.9.0")] pub fn space(&self) -> ColorSpace { match self { Self::Linear(linear) => linear.space.clone(), @@ -706,7 +768,7 @@ impl Gradient { } /// Returns the relative placement of this gradient. - #[func] + #[func(since = "0.9.0")] pub fn relative(&self) -> Smart { match self { Self::Linear(linear) => linear.relative, @@ -718,7 +780,7 @@ impl Gradient { /// Returns the angle of this gradient. /// /// Returns `{none}` if the gradient is neither linear nor conic. - #[func] + #[func(since = "0.9.0")] pub fn angle(&self) -> Option { match self { Self::Linear(linear) => Some(linear.angle), @@ -730,7 +792,7 @@ impl Gradient { /// Returns the center of this gradient. /// /// Returns `{none}` if the gradient is neither radial nor conic. - #[func] + #[func(since = "0.13.0")] pub fn center(&self) -> Option> { match self { Self::Linear(_) => None, @@ -742,7 +804,7 @@ impl Gradient { /// Returns the radius of this gradient. /// /// Returns `{none}` if the gradient is not radial. - #[func] + #[func(since = "0.13.0")] pub fn radius(&self) -> Option { match self { Self::Linear(_) => None, @@ -754,7 +816,7 @@ impl Gradient { /// Returns the focal-center of this gradient. /// /// Returns `{none}` if the gradient is not radial. - #[func] + #[func(since = "0.13.0")] pub fn focal_center(&self) -> Option> { match self { Self::Linear(_) => None, @@ -766,7 +828,7 @@ impl Gradient { /// Returns the focal-radius of this gradient. /// /// Returns `{none}` if the gradient is not radial. - #[func] + #[func(since = "0.13.0")] pub fn focal_radius(&self) -> Option { match self { Self::Linear(_) => None, @@ -780,7 +842,7 @@ impl Gradient { /// The position is either a position along the gradient (a @ratio[ratio] /// between `{0%}` and `{100%}`) or an @angle[angle]. Any value outside of /// this range will be clamped. - #[func] + #[func(since = "0.9.0")] pub fn sample( &self, /// The position at which to sample the gradient. @@ -801,7 +863,7 @@ impl Gradient { /// Samples the gradient at multiple positions at once and returns the /// results as an array. - #[func] + #[func(since = "0.9.0")] pub fn samples( &self, /// The positions at which to sample the gradient. diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index b703bb70c847..07709d31bdb5 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -48,7 +48,33 @@ use crate::visualize::image::pdf::PdfDocument; /// ], /// ) /// ``` -#[elem(Locatable, Tagged, Synthesize, LocalName, Figurable)] +/// +/// = Clipping +/// You can wrap an image in a @block or @box #footnote[A box should only be +/// used if the image shall be displayed inline as part of a paragraph. +/// Otherwise, a block is preferable.] with negative @block.inset[`inset`] and +/// `{clip: true}` to clip it. Note that the clipped parts are only visually +/// hidden. The full image data is still embedded in the output (except when +/// exporting to PNG). This approach is thus not suitable for redacting parts of +/// an image. +/// +/// ```example +/// #let lynx = image("lynx.jpg", height: 150pt, fit: "cover") +/// #grid( +/// columns: 2, +/// column-gutter: 1fr, +/// // The full image on the left. +/// lynx, +/// // Two cropped parts on the right: One cropped by 80pt +/// // from the bottom and one cropped by 75pt from the top. +/// stack( +/// spacing: 5pt, +/// block(lynx, clip: true, inset: (bottom: -80pt)), +/// block(lynx, clip: true, inset: (top: -75pt)), +/// ) +/// ) +/// ``` +#[elem(since = "forever", Locatable, Tagged, Synthesize, LocalName, Figurable)] pub struct ImageElem { /// A path to an image file or raw bytes making up an image in one of the /// supported @image.format[formats]. @@ -88,7 +114,7 @@ pub struct ImageElem { /// /// - When exporting to PDF, any PDF image file used must have a version /// equal to or lower than the - /// @pdf:pdf-versions[export target PDF version]. + /// @pdf.standard:pdf-versions[export target PDF version]. /// - PDF files as images are currently not supported when exporting with a /// specific PDF standard, like PDF/A-3 or PDF/UA-1. In these cases, you /// can instead use SVGs to embed vector images. diff --git a/crates/typst-library/src/visualize/image/svg.rs b/crates/typst-library/src/visualize/image/svg.rs index e59717a0e596..6a97e6cd6218 100644 --- a/crates/typst-library/src/visualize/image/svg.rs +++ b/crates/typst-library/src/visualize/image/svg.rs @@ -77,8 +77,8 @@ impl SvgImage { }, image_href_resolver: usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), - resolve_string: Box::new(|href, _opts| { - image_resolver.lock().unwrap().load(href) + resolve_string: Box::new(|href, opts| { + image_resolver.lock().unwrap().load(href, opts) }), }, ..base_options() @@ -340,11 +340,11 @@ impl<'a> ImageResolver<'a> { /// Load a linked image or return None if a previous image caused an error, /// or if the linked image failed to load. /// Only the first error message is retained. - fn load(&mut self, href: &str) -> Option { + fn load(&mut self, href: &str, opts: &usvg::Options) -> Option { if self.error.is_some() { return None; } - match self.load_or_error(href) { + match self.load_or_error(href, opts) { Ok(image) => Some(image), Err(err) => { self.error = Some(LoadError::text( @@ -358,7 +358,11 @@ impl<'a> ImageResolver<'a> { } /// Load a linked image or return an error message string. - fn load_or_error(&mut self, href: &str) -> StrResult { + fn load_or_error( + &mut self, + href: &str, + opts: &usvg::Options, + ) -> StrResult { // If the href starts with "file://", strip this prefix to construct an ordinary path. let href = href.strip_prefix("file://").unwrap_or(href); @@ -396,7 +400,9 @@ impl<'a> ImageResolver<'a> { match format { Some(ImageFormat::Vector(vector_format)) => match vector_format { VectorFormat::Svg => { - Err("SVG images are not supported yet".into()) + let tree = usvg::Tree::from_data_nested(&arc_data, opts) + .map_err(|_| "failed to parse SVG")?; + Ok(usvg::ImageKind::SVG(tree)) } VectorFormat::Pdf => { Err("PDF documents are not supported".into()) diff --git a/crates/typst-library/src/visualize/line.rs b/crates/typst-library/src/visualize/line.rs index 113c105a82f7..1b0e7d66bed0 100644 --- a/crates/typst-library/src/visualize/line.rs +++ b/crates/typst-library/src/visualize/line.rs @@ -15,7 +15,7 @@ use crate::visualize::Stroke; /// stroke: 2pt + maroon, /// ) /// ``` -#[elem] +#[elem(since = "forever")] pub struct LineElem { /// The start point of the line. /// diff --git a/crates/typst-library/src/visualize/polygon.rs b/crates/typst-library/src/visualize/polygon.rs index de06871c1076..0557b6afd034 100644 --- a/crates/typst-library/src/visualize/polygon.rs +++ b/crates/typst-library/src/visualize/polygon.rs @@ -21,7 +21,7 @@ use crate::visualize::{FillRule, Paint, Stroke}; /// (0%, 2cm), /// ) /// ``` -#[elem(scope)] +#[elem(scope, since = "0.1.0")] pub struct PolygonElem { /// How to fill the polygon. /// @@ -60,7 +60,7 @@ impl PolygonElem { /// vertices: 3, /// ) /// ``` - #[func(title = "Regular Polygon")] + #[func(title = "Regular Polygon", since = "0.8.0")] pub fn regular( span: Span, diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs index b52aff00b30c..07fb86790f60 100644 --- a/crates/typst-library/src/visualize/shape.rs +++ b/crates/typst-library/src/visualize/shape.rs @@ -16,7 +16,7 @@ use kurbo::{PathEl, Shape as _}; /// to fit the content. /// ] /// ``` -#[elem(title = "Rectangle")] +#[elem(title = "Rectangle", since = "forever")] pub struct RectElem { /// The rectangle's width, relative to its parent container. pub width: Smart>, @@ -140,7 +140,7 @@ pub struct RectElem { /// sized to fit. /// ] /// ``` -#[elem] +#[elem(since = "forever")] pub struct SquareElem { /// The square's side length. This is mutually exclusive with `width` and /// `height`. @@ -218,7 +218,7 @@ pub struct SquareElem { /// to fit the content. /// ] /// ``` -#[elem] +#[elem(since = "forever")] pub struct EllipseElem { /// The ellipse's width, relative to its parent container. pub width: Smart>, @@ -268,7 +268,7 @@ pub struct EllipseElem { /// sized to fit. /// ] /// ``` -#[elem] +#[elem(since = "forever")] pub struct CircleElem { /// The circle's radius. This is mutually exclusive with `width` and /// `height`. diff --git a/crates/typst-library/src/visualize/stroke.rs b/crates/typst-library/src/visualize/stroke.rs index 3851d53cf812..11d6bd474459 100644 --- a/crates/typst-library/src/visualize/stroke.rs +++ b/crates/typst-library/src/visualize/stroke.rs @@ -49,7 +49,7 @@ use crate::visualize::{Color, Gradient, Paint, Tiling}; /// constructor function. For example, `{(2pt + blue).thickness}` is `{2pt}`. /// Meanwhile, `{stroke(red).cap}` is `{auto}` because it's unspecified. Fields /// set to `{auto}` are inherited. -#[ty(scope, cast)] +#[ty(scope, cast, since = "forever")] #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] pub struct Stroke { /// The stroke's paint. @@ -96,7 +96,7 @@ impl Stroke { /// #my-func(red) \ /// #my-func(stroke(cap: "round", thickness: 1pt)) /// ``` - #[func(constructor)] + #[func(constructor, since = "forever")] pub fn construct( args: &mut Args, @@ -316,7 +316,7 @@ impl Repr for Stroke { } (Smart::Custom(paint), Smart::Auto) => r.push_str(&paint.repr()), (Smart::Auto, Smart::Custom(thickness)) => r.push_str(&thickness.repr()), - (Smart::Auto, Smart::Auto) => r.push_str("1pt + black"), + (Smart::Auto, Smart::Auto) => r.push_str("stroke()"), } } else { r.push('('); diff --git a/crates/typst-library/src/visualize/tiling.rs b/crates/typst-library/src/visualize/tiling.rs index 9ae8494ed9a5..f60ec8a47403 100644 --- a/crates/typst-library/src/visualize/tiling.rs +++ b/crates/typst-library/src/visualize/tiling.rs @@ -9,7 +9,7 @@ use crate::diag::{SourceResult, bail}; use crate::engine::Engine; use crate::foundations::{Content, Repr, Resolve, Smart, StyleChain, func, scope, ty}; use crate::introspection::Locator; -use crate::layout::{Abs, Axes, Frame, Length, Region, Rel, Size}; +use crate::layout::{Abs, Angle, Axes, Frame, Length, Region, Rel, Size, Transform}; use crate::visualize::RelativeTo; /// A repeating tiling fill. @@ -19,8 +19,9 @@ use crate::visualize::RelativeTo; /// filled or stroked. The pattern is defined by a tile /// @tiling.constructor.size[`size`] and a body defining the content of each /// cell. You can also add horizontal or vertical -/// @tiling.constructor.spacing[`spacing`] between the cells of the tiling and -/// @tiling.constructor.offset[`offset`] the starting position of the tiling. +/// @tiling.constructor.spacing[`spacing`] between the cells of the tiling. +/// The @tiling.constructor.offset[`offset`] and +/// @tiling.constructor.angle[`angle`] determine the placement of the tiling. /// /// = Example /// ```example @@ -53,7 +54,7 @@ use crate::visualize::RelativeTo; /// #set text(fill: pat) /// #lorem(10) /// ``` -#[ty(scope, cast, keywords = ["pattern"])] +#[ty(scope, cast, since = "0.13.0", keywords = ["pattern"])] #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Tiling(Arc); @@ -68,11 +69,14 @@ struct TilingInner { spacing: Size, /// The tiling's tile offset. offset: Size, + /// The tiling's tile angle. + angle: Angle, /// The tiling's relative transform. relative: Smart, } #[scope] +#[expect(clippy::too_many_arguments)] impl Tiling { /// Construct a new tiling. /// @@ -92,7 +96,7 @@ impl Tiling { /// /// #rect(width: 100%, height: 60pt, fill: pat) /// ``` - #[func(constructor)] + #[func(constructor, since = "0.13.0")] pub fn construct( engine: &mut Engine, span: Span, @@ -164,6 +168,21 @@ impl Tiling { #[named] #[default(Spanned::new(Axes::splat(Rel::zero()), Span::detached()))] offset: Spanned>>, + /// Rotates the tiles and the grid clockwise about the offset coordinates + /// by the specified angle. The rotation is applied after the offset. + /// + /// ```example + /// #let pat = tiling( + /// size: (20pt, 20pt), + /// angle: 45deg, + /// line(start: (0%, 50%), end: (100%, 50%)), + /// ) + /// + /// #rect(width: 100%, height: 60pt, fill: pat) + /// ``` + #[named] + #[default(Spanned::detached(Angle::zero()))] + angle: Spanned, /// Determines relative to which element's bounding box the tiling is /// drawn. /// @@ -200,7 +219,7 @@ impl Tiling { /// table.cell(fill: pat, none), /// /// // These two are both page-relative, so the - /// // pattern is continous. + /// // pattern is continuous. /// table.cell(fill: pat-with-parent, none), /// table.cell(fill: pat-with-parent, none), /// ) @@ -252,6 +271,10 @@ impl Tiling { bail!(offset.span, "tile offset must be finite"); } + if !angle.v.is_finite() { + bail!(angle.span, "tile angle must be finite"); + } + // The size of the frame let size = size.v.map(|l| l.map(|a| a.abs)); let region = size.unwrap_or_else(|| Axes::splat(Abs::inf())); @@ -288,6 +311,7 @@ impl Tiling { frame: LazyHash::new(frame), spacing, offset, + angle: angle.v, relative, }))) } @@ -313,6 +337,11 @@ impl Tiling { self.0.offset } + /// Return the rotation angle of the tiling. + pub fn angle(&self) -> Angle { + self.0.angle + } + /// Return the frame of the tiling. pub fn frame(&self) -> &Frame { &self.0.frame @@ -339,6 +368,13 @@ impl Tiling { if on_text { RelativeTo::Parent } else { RelativeTo::Self_ } }) } + + /// Returns the transform of the tiling from the `offset` and `angle` + /// parameters. + pub fn transform(&self) -> Transform { + Transform::translate(self.offset().x, self.offset().y) + .pre_concat(Transform::rotate(self.angle())) + } } impl Repr for Tiling { @@ -362,6 +398,11 @@ impl Repr for Tiling { out.push(')'); } + if self.0.angle != Angle::zero() { + out.push_str(", angle: "); + out.push_str(&self.0.angle.repr()); + } + out.push_str(", ..)"); out diff --git a/crates/typst-library/translations/eo.txt b/crates/typst-library/translations/eo.txt index 2f8cb938b4d6..b78f7e4c6679 100644 --- a/crates/typst-library/translations/eo.txt +++ b/crates/typst-library/translations/eo.txt @@ -7,5 +7,5 @@ outline = Enhavo raw = Listado page = Paĝo footnote = Piednoto -# email = -# telephone = +email = Retpoŝto +telephone = Telefono diff --git a/crates/typst-library/translations/fr.txt b/crates/typst-library/translations/fr.txt index ff34c2978406..9e28c8bd3f24 100644 --- a/crates/typst-library/translations/fr.txt +++ b/crates/typst-library/translations/fr.txt @@ -2,7 +2,7 @@ figure = Fig. table = Tableau equation = Équation bibliography = Bibliographie -heading = Chapitre +heading = Section outline = Table des matières raw = Liste page = page diff --git a/crates/typst-library/translations/hr.txt b/crates/typst-library/translations/hr.txt index e84ad82d2a40..260cacbd56eb 100644 --- a/crates/typst-library/translations/hr.txt +++ b/crates/typst-library/translations/hr.txt @@ -6,6 +6,6 @@ heading = Odjeljak outline = Sadržaj raw = Kôd page = str. -# footnote = -# email = -# telephone = +footnote = Fusnota +email = E-pošta +telephone = Telefon diff --git a/crates/typst-library/translations/za.txt b/crates/typst-library/translations/za.txt new file mode 100644 index 000000000000..6284ccc78a18 --- /dev/null +++ b/crates/typst-library/translations/za.txt @@ -0,0 +1,11 @@ +figure = Doz +table = Biuj +equation = Daengjsik +bibliography = Vwnzyen Doiqciuq +heading = Ciet +outline = Moegloeg +raw = Daihmax +page = yieb +footnote = Gejnaeuz +email = E-mail +telephone = Denva diff --git a/crates/typst-macros/src/cast.rs b/crates/typst-macros/src/cast.rs index 35c042bf06db..55ef20a3b04c 100644 --- a/crates/typst-macros/src/cast.rs +++ b/crates/typst-macros/src/cast.rs @@ -32,7 +32,7 @@ pub fn derive_cast(item: DeriveInput) -> Result { variants.push(Variant { ident: variant.ident.clone(), string, - docs: documentation(&variant.attrs), + docs: documentation(&variant.attrs)?, }); } @@ -72,7 +72,7 @@ pub fn cast(stream: TokenStream) -> Result { let input: CastInput = syn::parse2(stream)?; let ty = &input.ty; let castable_body = create_castable_body(&input); - let input_body = create_input_body(&input); + let input_body = create_input_body(&input)?; let output_body = create_output_body(&input); let into_value_body = create_into_value_body(&input); let from_value_body = create_from_value_body(&input); @@ -239,11 +239,11 @@ fn create_castable_body(input: &CastInput) -> TokenStream { } } -fn create_input_body(input: &CastInput) -> TokenStream { +fn create_input_body(input: &CastInput) -> Result { let mut infos = vec![]; for cast in &input.from_value { - let docs = documentation(&cast.attrs); + let docs = documentation(&cast.attrs)?; infos.push(match &cast.pattern { Pattern::Str(lit) => { quote! { @@ -265,9 +265,9 @@ fn create_input_body(input: &CastInput) -> TokenStream { }); } - quote! { + Ok(quote! { #(#infos)+* - } + }) } fn create_output_body(input: &CastInput) -> TokenStream { diff --git a/crates/typst-macros/src/elem.rs b/crates/typst-macros/src/elem.rs index acb4b0b8e117..d55162f048eb 100644 --- a/crates/typst-macros/src/elem.rs +++ b/crates/typst-macros/src/elem.rs @@ -6,8 +6,9 @@ use syn::punctuated::Punctuated; use syn::{Ident, Result, Token}; use crate::util::{ - BlockWithReturn, determine_name_and_title, documentation, foundations, has_attr, kw, - oneliner, parse_attr, parse_flag, parse_string, parse_string_array, validate_attrs, + BlockWithReturn, Since, determine_name_and_title, documentation, foundations, + has_attr, kw, oneliner, parse_attr, parse_flag, parse_key_value, parse_string, + parse_string_array, validate_attrs, }; /// Expand the `#[elem]` macro. @@ -22,6 +23,8 @@ struct Elem { name: String, /// The element's title case name. title: String, + /// The version of Typst the element was introduced in. + since: Option, /// Whether this element has an associated scope defined by the `#[scope]` macro. scope: bool, /// A list of alternate search terms for this element. @@ -128,6 +131,7 @@ struct Meta { scope: bool, name: Option, title: Option, + since: Option, keywords: Vec, capabilities: Vec, } @@ -138,6 +142,7 @@ impl Parse for Meta { scope: parse_flag::(input)?, name: parse_string::(input)?, title: parse_string::(input)?, + since: parse_key_value::(input)?, keywords: parse_string_array::(input)?, capabilities: Punctuated::::parse_terminated(input)? .into_iter() @@ -156,7 +161,7 @@ fn parse(stream: TokenStream, body: &syn::ItemStruct) -> Result { Some(|base| base.trim_end_matches("Elem")), )?; - let docs = documentation(&body.attrs); + let docs = documentation(&body.attrs)?; let syn::Fields::Named(named) = &body.fields else { bail!(body, "expected named fields"); @@ -180,6 +185,7 @@ fn parse(stream: TokenStream, body: &syn::ItemStruct) -> Result { Ok(Elem { name, title, + since: meta.since, scope: meta.scope, keywords: meta.keywords, docs, @@ -211,7 +217,7 @@ fn parse_field(field: &syn::Field) -> Result { vis: field.vis.clone(), ty: field.ty.clone(), name: ident.to_string().to_kebab_case(), - docs: documentation(&attrs), + docs: documentation(&attrs)?, positional, required, variadic, @@ -377,9 +383,17 @@ fn create_with_field_method(field: &Field) -> TokenStream { /// Creates the element's `NativeElement` implementation. fn create_native_elem_impl(element: &Elem) -> Result { - let Elem { name, ident, title, scope, keywords, docs, .. } = element; + let Elem { + name, ident, title, since, scope, keywords, docs, .. + } = element; let def_site_key = ident.to_string(); + let since = if let Some(since) = since { + quote! { Some(#since) } + } else { + quote! { None } + }; + let fields = element.fields.iter().filter(|field| !field.internal).map(|field| { let i = field.i; if field.external { @@ -431,6 +445,7 @@ fn create_native_elem_impl(element: &Elem) -> Result { #foundations::ContentVtable::new::<#ident>( #name, #title, + #since, #docs, ::typst_utils::DefSite { path: file!(), key: #def_site_key }, &[#(#fields),*], diff --git a/crates/typst-macros/src/func.rs b/crates/typst-macros/src/func.rs index b7e669f77a1d..37633144ffdc 100644 --- a/crates/typst-macros/src/func.rs +++ b/crates/typst-macros/src/func.rs @@ -5,7 +5,7 @@ use syn::parse::{Parse, ParseStream}; use syn::{Ident, Result, parse_quote}; use crate::util::{ - determine_name_and_title, documentation, foundations, has_attr, kw, oneliner, + Since, determine_name_and_title, documentation, foundations, has_attr, kw, oneliner, parse_attr, parse_flag, parse_key_value, parse_string, parse_string_array, quote_option, validate_attrs, }; @@ -22,11 +22,13 @@ struct Func { name: String, /// The function's title case name. title: String, + /// The version of Typst the function was introduced in. + since: Option, /// Whether this function has an associated scope defined by the `#[scope]` macro. scope: bool, /// Whether this function is a constructor. constructor: bool, - /// A list of alternate search terms for this element. + /// A list of alternate search terms for this function. keywords: Vec, /// The parent type of this function. /// @@ -34,9 +36,9 @@ struct Func { parent: Option, /// Whether this function is contextual. contextual: bool, - /// The documentation for this element as a string. + /// The documentation for this function as a string. docs: String, - /// The element's visibility. + /// The function's visibility. vis: syn::Visibility, /// The name for this function given in Rust. ident: Ident, @@ -106,9 +108,11 @@ pub struct Meta { pub name: Option, /// The function's title case name. pub title: Option, + /// The version of Typst the function was introduced in. + pub since: Option, /// Whether this function is a constructor. pub constructor: bool, - /// A list of alternate search terms for this element. + /// A list of alternate search terms for this function. pub keywords: Vec, /// The parent type of this function. /// @@ -124,6 +128,7 @@ impl Parse for Meta { name: parse_string::(input)?, title: parse_string::(input)?, constructor: parse_flag::(input)?, + since: parse_key_value::(input)?, keywords: parse_string_array::(input)?, parent: parse_key_value::(input)?, }) @@ -136,7 +141,7 @@ fn parse(stream: TokenStream, item: &syn::ItemFn) -> Result { let (name, title) = determine_name_and_title(meta.name, meta.title, &item.sig.ident, None)?; - let docs = documentation(&item.attrs); + let docs = documentation(&item.attrs)?; let mut special = SpecialParams::default(); let mut params = vec![]; @@ -156,6 +161,7 @@ fn parse(stream: TokenStream, item: &syn::ItemFn) -> Result { Ok(Func { name, title, + since: meta.since, scope: meta.scope, constructor: meta.constructor, keywords: meta.keywords, @@ -196,7 +202,7 @@ fn parse_param( None => bail!(recv, "explicit parent type required"), }, name: "self".into(), - docs: documentation(&recv.attrs), + docs: documentation(&recv.attrs)?, named: false, variadic: false, external: false, @@ -225,7 +231,7 @@ fn parse_param( ident: ident.clone(), ty: (*typed.ty).clone(), name: ident.to_string().to_kebab_case(), - docs: documentation(&attrs), + docs: documentation(&attrs)?, named: has_attr(&mut attrs, "named"), variadic: has_attr(&mut attrs, "variadic"), external: has_attr(&mut attrs, "external"), @@ -289,6 +295,7 @@ fn create_func_data(func: &Func) -> TokenStream { ident, name, title, + since, docs, keywords, returns, @@ -327,18 +334,25 @@ fn create_func_data(func: &Func) -> TokenStream { quote! { #name } }; + let since = if let Some(since) = since { + quote! { Some(#since) } + } else { + quote! { None } + }; + quote! { #foundations::NativeFuncData { function: #foundations::NativeFuncPtr(&#closure), name: #name, title: #title, + since: #since, docs: #docs, def_site: Some(::typst_utils::DefSite { path: file!(), key: #def_site_key }), keywords: &[#(#keywords),*], contextual: #contextual, scope: ::std::sync::LazyLock::new(&|| #scope), params: ::std::sync::LazyLock::new(&|| ::std::vec![#(#params),*]), - returns: ::std::sync::LazyLock::new(&|| <#returns as #foundations::Reflect>::output()), + returns: ::std::sync::LazyLock::new(&|| <#returns as #foundations::Reflect>::output()), } } } diff --git a/crates/typst-macros/src/scope.rs b/crates/typst-macros/src/scope.rs index 6e623ff76e5a..e6cfed14df5b 100644 --- a/crates/typst-macros/src/scope.rs +++ b/crates/typst-macros/src/scope.rs @@ -5,7 +5,7 @@ use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::{MetaNameValue, Result, Token, parse_quote}; -use crate::util::{BareType, foundations, kw, parse_flag}; +use crate::util::{BareType, foundations, kw, parse_flag, parse_ident}; /// Expand the `#[scope]` macro. pub fn scope(stream: TokenStream, item: syn::Item) -> Result { @@ -32,6 +32,7 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { let mut definitions = vec![]; let mut constructor = quote! { None }; + let mut custom_defs = None; for child in &mut item.items { let bare: BareType; let (mut def, attrs) = match child { @@ -45,6 +46,10 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { constructor = tokens; continue; } + FnKind::CustomDefs(tokens) => { + custom_defs = Some(tokens); + continue; + } }, &item.attrs, ), @@ -56,19 +61,17 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { }; if let Some(attr) = attrs.iter().find(|attr| attr.path().is_ident("deprecated")) { + let mut deprecation = quote! { #foundations::Deprecation::new() }; match &attr.meta { - syn::Meta::NameValue(pair) if pair.path.is_ident("deprecated") => { + syn::Meta::NameValue(pair) => { let message = &pair.value; - def = quote! { #def.deprecated(#message) } + deprecation = quote! { #deprecation.with_message(#message) } } - syn::Meta::List(list) if list.path.is_ident("deprecated") => { + syn::Meta::List(list) => { let args = list.parse_args_with( Punctuated::::parse_separated_nonempty, )?; - let mut deprecation = - quote! { crate::foundations::Deprecation::new() }; - if let Some(message) = args.iter().find_map(|pair| { pair.path.is_ident("message").then_some(&pair.value) }) { @@ -80,10 +83,20 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { }) { deprecation = quote! { #deprecation.with_until(#version) } } + } + syn::Meta::Path(_) => bail!(attr, "expected deprecation message"), + } + + def = quote! { #def.with_deprecation(#deprecation) } + } - def = quote! { #def.deprecated(#deprecation) } + if let Some(attr) = attrs.iter().find(|attr| attr.path().is_ident("feature")) { + match &attr.meta { + syn::Meta::NameValue(pair) => { + let feature = &pair.value; + def = quote! { #def.with_feature(::typst_library::Feature::#feature) } } - _ => {} + _ => bail!(attr, "expected feature name"), } } @@ -97,6 +110,10 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { Some(ident_ext) => rewrite_primitive_base(&item, ident_ext), }; + let category = meta.category.map(|category| { + quote! { scope.start_category(::typst_library::Category::#category); } + }); + Ok(quote! { #base @@ -108,7 +125,9 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { #[expect(deprecated)] fn scope() -> #foundations::Scope { let mut scope = #foundations::Scope::deduplicating(); + #category #(#definitions;)* + #custom_defs scope } } @@ -120,11 +139,16 @@ struct Meta { /// Whether this the scope should be implemented through an extension /// trait instead of an inherent impl. ext: bool, + /// The category of the scope. + category: Option, } impl Parse for Meta { fn parse(input: ParseStream) -> Result { - Ok(Self { ext: parse_flag::(input)? }) + Ok(Self { + ext: parse_flag::(input)?, + category: parse_ident::(input)?, + }) } } @@ -149,13 +173,41 @@ fn handle_type_or_elem(item: &BareType) -> Result { /// Process a function, return its definition, and register it as a constructor /// if applicable. fn handle_fn(self_ty: &syn::Type, item: &mut syn::ImplItemFn) -> Result { - let Some(attr) = item.attrs.iter_mut().find(|attr| attr.meta.path().is_ident("func")) - else { - bail!(item, "scope function is missing #[func] attribute"); - }; + let mut ret = Vec::new(); + item.attrs.retain_mut(|attr| { + if attr.meta.path().is_ident("func") { + let ident_data = quote::format_ident!("{}_data", item.sig.ident); + ret.push((attr.clone(), handle_fn_member(self_ty, ident_data, attr))); + // Retain, but possibly modify the `#[func]` attribute. + true + } else if attr.meta.path().is_ident("defs") { + let ident = &item.sig.ident; + let call = quote! { #self_ty::#ident(&mut scope); }; + ret.push((attr.clone(), Ok(FnKind::CustomDefs(call)))); + // Remove the `#[defs]` attribute. + false + } else { + true + } + }); - let ident_data = quote::format_ident!("{}_data", item.sig.ident); + if let [_, (attr, _), ..] = ret.as_slice() { + bail!( + attr, + "scope functions can only have a single #[func] or #[defs] attribute" + ); + } + match ret.pop() { + Some((_, res)) => res, + None => bail!(item, "scope function is missing #[func] or #[defs] attribute"), + } +} +fn handle_fn_member( + self_ty: &syn::Type, + ident_data: syn::Ident, + attr: &mut syn::Attribute, +) -> Result { match &mut attr.meta { syn::Meta::Path(_) => { *attr = parse_quote! { #[func(parent = #self_ty)] }; @@ -177,6 +229,7 @@ fn handle_fn(self_ty: &syn::Type, item: &mut syn::ImplItemFn) -> Result enum FnKind { Constructor(TokenStream), Member(TokenStream), + CustomDefs(TokenStream), } /// Rewrite an impl block for a primitive into a trait + trait impl. diff --git a/crates/typst-macros/src/ty.rs b/crates/typst-macros/src/ty.rs index 4de0071383a7..624a47c83fc8 100644 --- a/crates/typst-macros/src/ty.rs +++ b/crates/typst-macros/src/ty.rs @@ -4,8 +4,8 @@ use syn::parse::{Parse, ParseStream}; use syn::{Attribute, Ident, Result}; use crate::util::{ - BareType, determine_name_and_title, documentation, foundations, kw, oneliner, - parse_flag, parse_string, parse_string_array, + BareType, Since, determine_name_and_title, documentation, foundations, kw, oneliner, + parse_flag, parse_key_value, parse_string, parse_string_array, }; /// Expand the `#[ty]` macro. @@ -49,6 +49,7 @@ struct Meta { cast: bool, name: Option, title: Option, + since: Option, keywords: Vec, } @@ -59,6 +60,7 @@ impl Parse for Meta { cast: parse_flag::(input)?, name: parse_string::(input)?, title: parse_string::(input)?, + since: parse_key_value::(input)?, keywords: parse_string_array::(input)?, }) } @@ -66,7 +68,7 @@ impl Parse for Meta { /// Parse details about the type from its definition. fn parse(meta: Meta, ident: Ident, attrs: &[Attribute]) -> Result { - let docs = documentation(attrs); + let docs = documentation(attrs)?; let (name, title) = determine_name_and_title(meta.name.clone(), meta.title.clone(), &ident, None)?; let long = title.to_lowercase(); @@ -79,6 +81,11 @@ fn create(ty: &Type, item: Option<&syn::Item>) -> TokenStream { let Meta { keywords, .. } = meta; let def_site_key = ident.to_string(); let oneliner = oneliner(docs); + let since = if let Some(since) = &meta.since { + quote! { Some(#since) } + } else { + quote! { None } + }; let constructor = if meta.scope { quote! { <#ident as #foundations::NativeScope>::constructor() } @@ -103,6 +110,7 @@ fn create(ty: &Type, item: Option<&syn::Item>) -> TokenStream { name: #name, long_name: #long, title: #title, + since: #since, docs: #docs, def_site: ::typst_utils::DefSite { path: file!(), key: #def_site_key }, keywords: &[#(#keywords),*], diff --git a/crates/typst-macros/src/util.rs b/crates/typst-macros/src/util.rs index ba775701803f..30a78c683b16 100644 --- a/crates/typst-macros/src/util.rs +++ b/crates/typst-macros/src/util.rs @@ -1,9 +1,11 @@ use heck::{ToKebabCase, ToTitleCase}; use proc_macro2::TokenStream; use quote::{ToTokens, quote}; +use std::str::FromStr; use syn::parse::{Parse, ParseStream}; +use syn::spanned::Spanned; use syn::token::Token; -use syn::{Attribute, Ident, Result, Token}; +use syn::{Attribute, Error, Ident, Result, Token}; /// Return an error at the given item. macro_rules! bail { @@ -21,8 +23,22 @@ macro_rules! bail { }; } +/// A basic reimplementation of the `stringify!` macro. +/// +/// The `stringify!` macro does not expand eagerly so we have some very basic +/// support for int and float expressions here. This is e.g. used for paper +/// sizes. +fn stringify(tokens: TokenStream) -> Option { + let lit = syn::parse2::(tokens).ok()?; + match lit { + syn::Lit::Int(int) => Some(int.base10_digits().to_owned()), + syn::Lit::Float(float) => Some(float.base10_digits().to_owned()), + _ => None, + } +} + /// Extract documentation comments from an attribute list. -pub fn documentation(attrs: &[syn::Attribute]) -> String { +pub fn documentation(attrs: &[syn::Attribute]) -> Result { let mut doc = String::new(); // Parse doc comments. @@ -38,24 +54,22 @@ pub fn documentation(attrs: &[syn::Attribute]) -> String { doc.push_str(line); doc.push('\n'); } else if let syn::Expr::Macro(expr) = &meta.value - // The `stringify!` macro does not expand eagerly so we have - // some very basic support for int and float expressions here. - // This is e.g. used for paper sizes. && expr.mac.path.is_ident("stringify") - && let Ok(lit) = syn::parse2::(expr.mac.tokens.clone()) - && let Some(value) = match &lit { - syn::Lit::Int(int) => Some(int.base10_digits()), - syn::Lit::Float(float) => Some(float.base10_digits()), - _ => None, - } { - doc.push_str(value); + let value = stringify(expr.mac.tokens.clone()).ok_or_else(|| { + Error::new( + expr.span(), + "the `stringify!` macro is not fully supported in \ + the Typst documentation", + ) + })?; + doc.push_str(&value); doc.push('\n'); } } } - doc.trim().into() + Ok(doc.trim().into()) } /// Whether an attribute list has a specified attribute. @@ -132,6 +146,13 @@ pub fn parse_key_value_array( Ok(parse_key_value::>(input)?.map_or(vec![], |array| array.0)) } +/// Parse an metadata key-identifier pair, separated by `=`. +pub fn parse_ident( + input: ParseStream, +) -> Result> { + parse_key_value::(input) +} + /// Parse a metadata key-string pair, separated by `=`. pub fn parse_string( input: ParseStream, @@ -271,15 +292,82 @@ pub mod kw { syn::custom_keyword!(name); syn::custom_keyword!(span); syn::custom_keyword!(title); + syn::custom_keyword!(since); syn::custom_keyword!(scope); - syn::custom_keyword!(contextual); + syn::custom_keyword!(category); syn::custom_keyword!(cast); + syn::custom_keyword!(contextual); syn::custom_keyword!(constructor); + syn::custom_keyword!(custom); + syn::custom_keyword!(feature); syn::custom_keyword!(keywords); syn::custom_keyword!(parent); syn::custom_keyword!(ext); } +/// When a feature was introduced. +pub enum Since { + /// The feature was introduced before Typst 0.1.0. + Forever, + /// The feature was introduced in a version released after Typst 0.1.0. + Version([u32; 3]), + /// The feature is not present in any official Typst release. + Unreleased, +} + +impl Since { + const FOREVER: &'static str = "forever"; + const UNRELEASED: &'static str = "unreleased"; +} + +impl FromStr for Since { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + if s == Self::FOREVER { + Ok(Self::Forever) + } else if s == Self::UNRELEASED { + Ok(Self::Unreleased) + } else if let Ok(&[major, minor, patch]) = s + .splitn(3, '.') + .map(u32::from_str) + .collect::, _>>() + .as_deref() + { + Ok(Self::Version([major, minor, patch])) + } else { + Err(()) + } + } +} + +impl Parse for Since { + fn parse(input: ParseStream) -> Result { + let value = input.parse::()?; + let Ok(since) = value.value().parse() else { + bail!( + value, + "invalid version; use `{:?}` for an unreleased version", + Self::UNRELEASED, + ) + }; + Ok(since) + } +} + +impl ToTokens for Since { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Self::Forever => quote! { #foundations::Since::Forever }, + Self::Version([major, minor, patch]) => { + quote! { #foundations::Since::Version([#major, #minor, #patch]) } + } + Self::Unreleased => quote! { #foundations::Since::Unreleased }, + } + .to_tokens(tokens); + } +} + /// Extract the first line of documentation. pub fn oneliner(docs: &str) -> String { let paragraph = docs.split("\n\n").next().unwrap_or_default(); diff --git a/crates/typst-pdf/src/attach.rs b/crates/typst-pdf/src/attach.rs index d61b67414213..8e16c0cf63b7 100644 --- a/crates/typst-pdf/src/attach.rs +++ b/crates/typst-pdf/src/attach.rs @@ -5,10 +5,9 @@ use krilla::embed::{AssociationKind, EmbeddedFile, MimeType}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::{NativeElement, Smart, StyleChain}; use typst_library::introspection::Introspector; -use typst_library::pdf::{AttachElem, AttachedFileRelationship}; use crate::convert::GlobalContext; -use crate::metadata; +use crate::{AttachElem, AttachedFileRelationship, metadata}; pub(crate) fn attach_files( gc: &GlobalContext, diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index b8b9943caf86..dd4a6350296d 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -2,9 +2,7 @@ use comemo::Tracked; use ecow::{EcoString, EcoVec, eco_format}; use indexmap::IndexMap; use krilla::configure::validate::VersionedFeature; -use krilla::configure::{ - Configuration, PdfVersion, ValidationError, Validator, Validators, -}; +use krilla::configure::{PdfVersion, ValidationError, Validator, Validators}; use krilla::destination::NamedDestination; use krilla::embed::EmbedError; use krilla::error::{KrillaError, LimitError}; @@ -21,15 +19,15 @@ use typst_layout::PagedDocument; use typst_library::diag::{ At, ExpectInternal, SourceDiagnostic, SourceResult, bail, error, }; +use typst_library::format::Complete; use typst_library::foundations::{NativeElement, Repr}; use typst_library::introspection::{Introspector, Location, PagedPosition, Tag}; use typst_library::layout::{Abs, Frame, FrameItem, GroupItem, Sides, Size, Transform}; -use typst_library::model::{HeadingElem, LateLinkResolver}; +use typst_library::model::{Document as _, HeadingElem, LateLinkResolver}; use typst_library::text::FontInstance; use typst_library::visualize::{Geometry, Paint, SpotColorantName}; use typst_syntax::Span; -use crate::PdfOptions; use crate::attach::attach_files; use crate::image::handle_image; use crate::link::{LinkAnnotation, handle_link}; @@ -43,6 +41,7 @@ use crate::util::{ AbsExt, SpotColorantFromNameExt, TransformExt, ValidatorsExt, convert_path, display_font, }; +use crate::{PdfFormat, PdfOptions}; #[typst_macros::time(name = "convert document")] pub fn convert( @@ -50,21 +49,27 @@ pub fn convert( options: &PdfOptions, anchors: &[(Location, EcoString)], link_resolver: Option>, + warnings: &mut EcoVec, ) -> SourceResult> { + let options = + options.resolve(typst_document.options().get::(), warnings)?; + + let tags = tags::init(typst_document, &options)?; + let settings = SerializeSettings { - compress_content_streams: !options.pretty, + compress_content_streams: !options.format.pretty.v, no_device_cs: true, - ascii_compatible: options.pretty, + ascii_compatible: options.format.pretty.v, xmp_metadata: true, cmyk_profile: None, - configuration: options.standards.config, - enable_tagging: options.tagged, + configuration: options.format.standard.v.config, + enable_tagging: options.tagged(), render_svg_glyph_fn: render_svg_glyph, - pretty: options.pretty, + pretty: options.format.pretty.v, }; let mut document = Document::new_with(settings); - let page_index_converter = PageIndexConverter::new(typst_document, options); + let page_index_converter = PageIndexConverter::new(typst_document, &options); let named_destinations = collect_named_destinations( &mut document, typst_document, @@ -72,11 +77,9 @@ pub fn convert( &page_index_converter, ); - let tags = tags::init(typst_document, options)?; - let mut gc = GlobalContext::new( typst_document, - options, + &options, link_resolver, named_destinations, page_index_converter, @@ -91,7 +94,7 @@ pub fn convert( document.set_metadata(build_metadata(&gc, doc_lang)); document.set_tag_tree(tree); - finish(document, gc, options.standards.config) + finish(document, gc) } fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResult<()> { @@ -290,7 +293,7 @@ pub(crate) struct GlobalContext<'a> { /// The document to convert. pub(crate) document: &'a PagedDocument, /// Options for PDF export. - pub(crate) options: &'a PdfOptions, + pub(crate) options: &'a PdfOptions, /// Used to resolve cross-document links in bundle export. pub(crate) link_resolver: Option>>, /// Mapping between locations in the document and named destinations. @@ -303,7 +306,7 @@ pub(crate) struct GlobalContext<'a> { impl<'a> GlobalContext<'a> { pub(crate) fn new( document: &'a PagedDocument, - options: &'a PdfOptions, + options: &'a PdfOptions, link_resolver: Option>>, loc_to_names: FxHashMap, page_index_converter: PageIndexConverter, @@ -431,14 +434,10 @@ pub(crate) fn handle_group( /// Finish a krilla document and handle export errors. #[typst_macros::time(name = "finish export")] -fn finish( - document: Document, - gc: GlobalContext, - configuration: Configuration, -) -> SourceResult> { +fn finish(document: Document, gc: GlobalContext) -> SourceResult> { match document.finish() { - Ok(r) => Ok(r), - Err(e) => match e { + Ok(bytes) => Ok(bytes), + Err(err) => match err { KrillaError::Font(f, err) => { bail!( Span::detached(), @@ -446,17 +445,12 @@ fn finish( display_font(gc.fonts_backward.get(&f)); hint: "make sure the font is valid"; hint: "the used font might be unsupported by Typst"; - ); - } - KrillaError::Validation(ve) => { - let errors = ve - .iter() - .map(|(e, validators)| { - convert_error(&gc, *validators, e, configuration.version()) - }) - .collect::>(); - Err(errors) + ) } + KrillaError::Validation(ve) => Err(ve + .iter() + .map(|(e, validators)| convert_validation_error(&gc, e, *validators)) + .collect()), KrillaError::Image(_, loc, err) => { let span = to_span(loc); bail!(span, "failed to process image ({err})"); @@ -477,15 +471,14 @@ fn finish( "invalid page number for PDF file"; hint: "please report this as a bug"; ), - PdfError::VersionMismatch(v) => { - let pdf_ver = v.as_str(); - let config_ver = configuration.version(); - let cur_ver = config_ver.as_str(); + PdfError::VersionMismatch(embedded) => { + let embedded = embedded.as_str(); + let target = gc.options.version().as_str(); bail!(span, "the version of the PDF is too high"; - hint: "the current export target is {cur_ver}, while the PDF \ - has version {pdf_ver}"; - hint: "raise the export target to {pdf_ver} or higher"; + hint: "the current export target is {target}, while the PDF \ + has version {embedded}"; + hint: "raise the export target to {embedded} or higher"; hint: "or preprocess the PDF to convert it to a lower version"; ); } @@ -533,12 +526,12 @@ fn finish( } /// Converts a krilla error into a Typst error. -fn convert_error( +fn convert_validation_error( gc: &GlobalContext, - failing_validators: Validators, error: &ValidationError, - pdf_version: PdfVersion, + failing_validators: Validators, ) -> SourceDiagnostic { + let pdf_version = gc.options.version().as_str(); let prefix = eco_format!("{} error:", failing_validators.to_comma_list()); match error { ValidationError::TooLongString => error!( @@ -774,20 +767,17 @@ fn convert_error( let message = match feature { VersionedFeature::StructureOrderTabbing => { eco_format!( - "{prefix} links and other annotations cannot be navigated accessibly in {} files", - pdf_version.as_str() + "{prefix} links and other annotations cannot be navigated accessibly in {pdf_version} files" ) } VersionedFeature::HeaderFooterArtifactSubtypes => { eco_format!( - "{prefix} headers and footers cannot be made accessible in {} files", - pdf_version.as_str() + "{prefix} headers and footers cannot be made accessible in {pdf_version} files" ) } VersionedFeature::TableHeaderScope => { eco_format!( - "{prefix} table header cell cannot be accessibly tagged in {} files", - pdf_version.as_str() + "{prefix} table header cell cannot be accessibly tagged in {pdf_version} files" ) } }; @@ -797,7 +787,7 @@ fn convert_error( let min_version = feature.minimum_pdf_version(); let mut most_restrictive: Option<(Validator, PdfVersion)> = None; - for validator in gc.options.standards.config.validators() { + for validator in gc.options.validators() { let max = validator.max(); if most_restrictive.is_none_or(|(_, prev_max)| prev_max > max) { most_restrictive = Some((validator, max)); @@ -890,15 +880,13 @@ pub(crate) struct PageIndexConverter { } impl PageIndexConverter { - pub fn new(document: &PagedDocument, options: &PdfOptions) -> Self { + pub fn new(document: &PagedDocument, options: &PdfOptions) -> Self { let mut page_indices = FxHashMap::default(); let mut skipped_pages = 0; for i in 0..document.pages().len() { - if options - .page_ranges - .as_ref() - .is_some_and(|ranges| !ranges.includes_page_index(i)) + if let Some(ranges) = &options.format.pages.v + && !ranges.includes_page_index(i) { skipped_pages += 1; } else { diff --git a/crates/typst-pdf/src/format.rs b/crates/typst-pdf/src/format.rs new file mode 100644 index 000000000000..77a5ac6df857 --- /dev/null +++ b/crates/typst-pdf/src/format.rs @@ -0,0 +1,953 @@ +//! PDF-specific functionality. + +use std::fmt::{Debug, Formatter}; +use std::hash::Hash; +use std::num::NonZeroU32; + +use ecow::{EcoString, eco_format}; +use krilla::configure::{Accessibility, Archival, PdfVersion, Validator}; +use serde::{Deserialize, Serialize}; +use typst_library::World; +use typst_library::diag::{ + At, HintedStrResult, HintedString, SourceResult, StrResult, bail, +}; +use typst_library::engine::Engine; +use typst_library::format::{Complete, Fields, Format, FormatElement, Partial, Populate}; +use typst_library::foundations::{ + Args, Array, Bytes, Construct, Content, Derived, IntoValue, NativeElement, + NativeRuleMap, PathOrStr, ShowFn, Smart, StyleChain, Target, Value, +}; +use typst_library::layout::PageRanges; +use typst_library::model::{ + ArtifactElem, TableCell, TableCellKind, TableElem, TableHeaderScope, +}; +use typst_macros::{Cast, cast, elem, func, scope}; +use typst_syntax::Spanned; +use typst_utils::NonZeroExt; + +/// The format element for registering the PDF format. +pub const FORMAT: Format = Format::new::().with_rules(register); + +/// Registers show rules for PDF specific elements. +pub fn register(rules: &mut NativeRuleMap) { + rules.register(Target::Paged, ATTACH_RULE); +} + +const ATTACH_RULE: ShowFn = |_, _, _| Ok(Content::empty()); + +/// Typst's PDF export format. +/// +/// PDF files focus on accurately describing documents visually, but also have +/// facilities for annotating their structure. This hybrid approach makes them a +/// good fit for document exchange: They render exactly the same on every +/// device, but also support extraction of a document's content and structure +/// (at least to an extent). Unlike PNG files, PDFs are not bound to a specific +/// resolution. Hence, you can view them at any size without incurring a loss of +/// quality. +/// +/// = Exporting as PDF +/// == Command Line +/// PDF is Typst's default export format. Running the `compile` or `watch` +/// subcommand without specifying a format will create a PDF. When exporting to +/// PDF, you have the following configuration options: +/// +/// - Which @pdf.standard[PDF standards] Typst should enforce conformance with +/// by specifying `--pdf-standard` followed by one or multiple comma-separated +/// standards. Valid standards are `1.4`, `1.5`, `1.6`, `1.7`, `2.0`, `a-1b`, +/// `a-1a`, `a-2b`, `a-2u`, `a-2a`, `a-3b`, `a-3u`, `a-3a`, `a-4`, `a-4f`, +/// `a-4e`, and `ua-1`. By default, Typst outputs PDF-1.7-compliant files. +/// +/// - You can disable PDF tagging completely with `--pdf-tagged=false`. By +/// default, Typst will always write _Tagged PDF_ to provide a baseline level +/// of accessibility. Using this flag, you can turn tags off. This will make +/// your file inaccessible and prevent conformance with accessible conformance +/// levels of PDF/A and all parts of PDF/UA. +/// +/// - Which pages to export by specifying `--pages` followed by a +/// comma-separated list of numbers or dash-separated number ranges. Ranges +/// can be half-open. Example: `2,3,7-9,11-`. +/// +/// == Web App +/// Click the quick download button at the top right to export a PDF with +/// default settings. For further configuration, click "File" > "Export as" > +/// "PDF" or click the downwards-facing arrow next to the quick download button +/// and select "Export as PDF". When exporting to PDF, you have the following +/// configuration options: +/// +/// - Which PDF standards Typst should enforce conformance with. By default, +/// Typst outputs PDF-1.7-compliant files. You can choose the PDF version +/// freely between 1.4 and 2.0. Valid additional standards are `A-1b`, `A-1a`, +/// `A-2b`, `A-2u`, `A-2a`, `A-3b`. `A-3u`, `A-3a`, `A-4`, `A-4f`, `A-4e`, and +/// `UA-1`. +/// +/// - Which pages to export. Valid options are "All pages", "Current page", and +/// "Custom ranges". Custom ranges are a comma-separated list of numbers or +/// dash-separated number ranges. Ranges can be half-open. Example: +/// `2,3,7-9,11-`. +/// +/// = PDF-specific functionality +/// Typst exposes PDF-specific functionality in the global `pdf` element. See +/// below for the definitions and options it contains. +/// +/// This element contains some functions without a final API. They are designed +/// to enhance accessibility for documents with complex tables. This includes +/// @[`table-summary`], @pdf.header-cell[`header-cell`], and +/// @pdf.data-cell[`data-cell`]. All of these functions will be removed in a +/// future Typst release, either through integration into table functions or +/// through full removal. You can enable these functions by passing `--features +/// a11y-extras` or setting the `TYPST_FEATURES` environment variable to +/// `a11y-extras`. In the web app, these features are not available at this +/// time. +#[elem(scope, name = "pdf", title = "PDF", since = "0.13.0", Construct)] +pub struct PdfFormat { + /// Specifies which ranges of pages should be included in the PDF. This can + /// be: + /// + /// - `{none}` to export all pages. + /// - A single page range. + /// - An array of page ranges. + /// + /// A page range is one of: + /// + /// - A number that specifies exactly one page (page numbers start at one). + /// - A dictionary with the keys `from` (indicating the start of a page + /// range) and/or `to` (indicating the end of a page range). At least one + /// of the keys must be present, and both the start and end numbers are + /// inclusive. Thus the range `{(from: 1, to: 3)}`, is the list of pages + /// `{(1, 2, 3)}`. + /// + /// Specifying a single page or page range: + /// ```typ + /// #set pdf(pages: 3) + /// #set pdf(pages: (from: 3)) + /// #set pdf(pages: (to: 5)) + /// #set pdf(pages: (from: 3, to: 5)) + /// ``` + /// + /// Specifying multiple pages, page ranges, or a mix: + /// ```typ + /// #set pdf(pages: (5, 7, 9)) + /// #set pdf(pages: ((to: 3), 5, (from: 7, to: 8), (from: 10))) + /// ``` + #[default] + pub pages: Option, + + /// A list of PDF standards that Typst will enforce conformance with. This + /// can be a single standard or an array of standards. + /// + /// The International Standards Organization (ISO) has published the base + /// PDF standard and various standards that extend it to make PDFs more + /// suitable for specific use-cases. By default, Typst exports PDF 1.7 + /// files. Adobe Acrobat 8 and later as well as all other commonly used PDF + /// viewers are compatible with this PDF version. + /// + /// Some features of Typst may not be available depending on the PDF + /// standard you choose. You can combine compatible PDF/A and PDF/UA + /// standards. Notably, PDF/UA-1 and PDF/A-4 are mutually incompatible since + /// the former requires PDF 1.7 or earlier and the latter requires PDF 2.0. + /// + /// = PDF versions + /// Typst supports five different PDF versions: 1.4, 1.5, 1.6, 1.7 + /// (default), and 2.0. You can choose each of these versions for your + /// document export. However, based on the features you used there may be a + /// minimum version. Likewise, the standards you target can limit which + /// versions you can choose (see below for more). + /// + /// Here is a list on how each new version improves over PDF 1.4 for Typst + /// documents: + /// + /// - *PDF 1.5* (2003): Improved color management, text extraction, table + /// accessibility, reflow, and emoji fonts + /// - *PDF 1.6* (2004): More flexible links + /// - *PDF 1.7* (2006): Allows @pdf.attach[attachments], improved reflow + /// - *PDF 2.0* (2017): Improved both metadata and tag semantics for + /// accessibility + /// + /// The software used to read your file must support your PDF version. Under + /// normal circumstances, this poses no problem, but it can be a source of + /// errors when working with older hardware. For general exchange, we + /// recommend keeping the default PDF 1.7 setting or choosing PDF 2.0. + /// + /// When using PDF files as @image[images] in your document, the export PDF + /// version must equal or exceed the image file versions. + /// + /// = PDF/UA + /// Typst supports writing PDF/UA-conformant files. PDF/UA files are + /// designed for _@guides:accessibility:basics[Universal Access]._ When you + /// choose this PDF standard, Typst will run additional checks when + /// exporting your document. These checks will make sure that you are + /// following accessibility best practices. For example, it will make sure + /// that all your images come with alternative descriptions. + /// + /// Note that there are some rules in PDF/UA that are crucial for + /// accessibility but cannot be automatically checked. Hence, when exporting + /// a PDF/UA-1 document, make sure you did the following: + /// + /// - If your document is written in a different language than English, make + /// sure @text.lang[set the text language] before any content. + /// - Make sure to use Typst's semantic elements (like @heading[headings], + /// @figure[figures], and @list[lists]) when appropriate instead of + /// defining custom constructs. This lets Typst know (and export) the role + /// a construct plays in the document. See @guides:accessibility[the + /// Accessibility guide] for more details. + /// - Do not exclusively use contrast, color, format, or layout to + /// communicate an idea. Use text or alternative descriptions instead of + /// or in addition to these elements. + /// - Wrap all decorative elements without a semantic meaning in + /// @pdf.artifact. + /// - Do not use images of text. Instead, insert the text directly into your + /// markup. + /// + /// Typst currently only supports part one (PDF/UA-1) which is based on PDF + /// 1.7 (2006). When exporting to PDF/UA-1, be aware that you will need to + /// manually provide @math:accessibility[alternative descriptions of + /// mathematics] in natural language. + /// + /// New accessibility features were added to PDF 2.0 (2017). When set to PDF + /// 2.0 export, Typst will leverage some of these features. PDF 2.0 and + /// PDF/UA-1, however, are mutually incompatible. For accessible documents, + /// we currently recommend exporting to PDF/UA-1 instead of PDF 2.0 for the + /// additional checks and greater compatibility. The second part of PDF/UA + /// is designed for PDF 2.0, but not yet supported by Typst. + /// + /// = PDF/A + /// Typst optionally supports emitting PDF/A-conformant files. PDF/A files + /// are geared towards maximum compatibility with current and future PDF + /// tooling. They do not rely on difficult-to-implement or proprietary + /// features and contain exhaustive metadata. This makes them suitable for + /// long-term archival. + /// + /// The PDF/A Standard has multiple versions (_parts_ in ISO terminology) + /// and most parts have multiple profiles that indicate the file's + /// conformance level. You can target one part and conformance level at a + /// time. Currently, Typst supports these PDF/A output profiles: + /// + /// - *PDF/A-1b:* The _basic_ conformance level of ISO 19005-1. This version + /// of PDF/A is based on PDF 1.4 (2001) and results in self-contained, + /// archivable PDF files. As opposed to later parts of the PDF/A standard, + /// transparency is not allowed in PDF/A-1 files. + /// + /// - *PDF/A-1a:* This is the _accessible_ conformance level that builds on + /// the basic level PDF/A-1b. To conform to this level, your file must be + /// an accessible _Tagged PDF_ file. Note that accessibility is improved + /// with later parts as not all PDF accessibility features are available + /// for PDF 1.4 files. Furthermore, all text in the file must consist of + /// known Unicode code points. + /// + /// - *PDF/A-2b:* The _basic_ conformance level of ISO 19005-2. This version + /// of PDF/A is based on PDF 1.7 (2006) and results in self-contained, + /// archivable PDF files. + /// + /// - *PDF/A-2u:* This is the _Unicode-mappable_ conformance level that + /// builds on the basic level A-2b. It also adds rules that all text in + /// the document must consist of known Unicode code points. If possible, + /// always prefer this standard over PDF/A-2b. + /// + /// - *PDF/A-2a:* This is the _accessible_ conformance level that builds on + /// the Unicode-mappable level A-2u. This conformance level also adds two + /// requirements: Your file must be an accessible _Tagged PDF_ file. Typst + /// automatically adds tags to help you reach this conformance level. Also + /// pay attention to the Accessibility Sections throughout the reference + /// and the @guides:accessibility[Accessibility Guide] when targeting this + /// conformance level. Finally, PDF/A-2a forbids you from using code + /// points in the + /// #link("https://en.wikipedia.org/wiki/Private_Use_Areas")[Unicode + /// Private Use area]. If you want to build an accessible file, also + /// consider additionally targeting PDF/UA-1, which enables more automatic + /// accessibility checks. + /// + /// - *PDF/A-3b:* The _basic_ conformance level of ISO 19005-3. This version + /// of PDF/A is based on PDF 1.7 (2006) and results in archivable PDF + /// files that can contain arbitrary other related files as + /// @pdf.attach[attachments]. The only difference between it and PDF/A-2b + /// is the capability to attach non-PDF/A-conformant files. + /// + /// - *PDF/A-3u:* This is the _Unicode-mappable_ conformance level that + /// builds on the basic level A-3b. Just like PDF/A-2b, this requires all + /// text to consist of known Unicode code points. These rules do not apply + /// to attachments. If possible, always prefer this standard over + /// PDF/A-3b. + /// + /// - *PDF/A-3a:* This is the _accessible_ conformance level that builds on + /// the Unicode-mappable level A-3u. Just like PDF/A-2a, this requires + /// files to be accessible _Tagged PDF_ and to not use characters from the + /// Unicode Private Use area. Just like before, these rules do not apply + /// to attachments. + /// + /// - *PDF/A-4:* The basic conformance level of ISO 19005-4. This version of + /// PDF/A is based on PDF 2.0 (2017) and results in self-contained, + /// archivable PDF files. PDF/A-4 has no parts relating to accessibility. + /// Instead, the topic has been elaborated on more in the dedicated PDF/UA + /// standard. PDF/A-4 files can conform to PDF/UA-2 (currently not + /// supported in Typst). + /// + /// - *PDF/A-4f:* The _embedded files_ conformance level that builds on the + /// basic level A-4. Files conforming to this level can contain arbitrary + /// other related files as @pdf.attach[attachments], just as files + /// conforming to part 3 of ISO 19005. The only difference between it and + /// PDF/A-4 is the capability to attach non-PDF/A-conformant files. + /// + /// - *PDF/A-4e:* The _engineering_ conformance level that builds on the + /// embedded files level A-4f. Files conforming to this level can contain + /// 3D objects. Typst does not support 3D content, so this is functionally + /// equivalent to PDF/A-4f from a Typst perspective. + /// + /// If you want to target PDF/A but are unsure about which particular + /// setting to use, there are some good rules of thumb. First, you must + /// determine your *part,* that's the "version number" of the standard. Ask + /// yourself these questions: + /// + /// + *Does pre-2006 software or equipment need to be able to read my file?* + /// If so, choose part one (PDF/A-1). + /// + /// + *If not, does my file need @pdf.attach[attachments]?* If so, you must + /// choose part three (PDF/A-3) or the embedded files level of part four + /// (PDF/A-4f). + /// + /// + *Does your file need to use features introduced in PDF 2.0?* + /// Currently, use of PDF 2.0 features in Typst is limited to minor + /// improvements in accessibility, e.g. for the @title[title element]. If + /// you can do without these improvements, maximize compatibility by + /// choosing part two (when you don't need attachments) or part three + /// (when you do need attachments). If you rely on PDF 2.0 features, use + /// part four. + /// + /// Now, you only need to choose a *conformance level* (the lowercase letter + /// at the end). + /// + /// - *If you decided on part one, two, or three,* you should typically + /// choose the accessible conformance level of your part, indicated by a + /// lowercase `a` at the end. If your document is inherently inaccessible, + /// e.g. an artist's portfolio that cannot be boiled down to alternative + /// descriptions, choose conformance level `u` instead. Only if this + /// results in a compiler error, e.g. because you used code points from + /// the Unicode Private Use Area, use the basic level `b`. + /// + /// - *If you have chosen part four,* you should choose the basic + /// conformance level (PDF/A-4) except when needing to embed files. + /// + /// When choosing between exporting PDF/A and regular PDF, keep in mind that + /// PDF/A files contain additional metadata, and that some readers will + /// prevent the user from modifying a PDF/A file. + #[default] + pub standard: PdfStandards, + + /// Whether to produce a tagged PDF document. + /// + /// Tagging is enabled by default to provide a baseline of accessibility. It + /// can be turned off manually, e.g. to reduce the size of the document, and + /// will be disabled automatically when exporting a specific page range. + #[default] + pub tagged: Smart, + + /// Whether to pretty-print the produced PDF document. + /// + /// This formats the output in a more human-readable, but less + /// space-efficient way. + #[default(false)] + pub pretty: bool, +} + +impl FormatElement for PdfFormat { + type Options = PdfFormatOptions; +} + +impl Construct for PdfFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +#[scope(category = Pdf)] +impl PdfFormat { + #[elem] + type AttachElem; + + #[elem] + type ArtifactElem; + + /// A summary of the purpose and structure of a complex table. + /// + /// This will be available for Assistive Technology (AT), such as screen + /// readers, when exporting to PDF, but not for sighted readers of your file. + /// + /// This field is intended for instructions that help the user navigate the + /// table using AT. It is not an alternative description, so do not duplicate + /// the contents of the table within. Likewise, do not use this for the core + /// takeaway of the table. Instead, include that in the text around the table + /// or, even better, in a @figure.caption[figure caption]. + /// + /// If in doubt whether your table is complex enough to warrant a summary, err + /// on the side of not including one. If you are certain that your table is + /// complex enough, consider whether a sighted user might find it challenging. + /// They might benefit from the instructions you put here, so consider printing + /// them visibly in the document instead. + /// + /// The API of this feature is temporary. Hence, calling this function requires + /// enabling the `a11y-extras` feature flag at the moment. Even if this + /// functionality should be available without a feature flag in the future, the + /// summary will remain exclusive to PDF export. + /// + /// ```example + /// #figure( + /// pdf.table-summary( + /// // The summary just provides orientation and structural + /// // information for AT users. + /// summary: "The first two columns list the names of each participant. The last column contains cells spanning multiple rows for their assigned group.", + /// table( + /// columns: 3, + /// table.header[First Name][Given Name][Group], + /// [Mike], [Davis], table.cell(rowspan: 3)[Sales], + /// [Anna], [Smith], + /// [John], [Johnson], + /// [Sara], [Wilkins], table.cell(rowspan: 2)[Operations], + /// [Tom], [Brown], + /// ), + /// ), + /// // This is the key takeaway of the table, so we put it in the caption. + /// caption: [The Sales org now has a new member], + /// ) + /// ``` + #[func(since = "0.14.0")] + #[feature = A11yExtras] + pub fn table_summary( + #[named] summary: Option, + /// The table. + table: TableElem, + ) -> Content { + table.with_summary(summary).pack() + } + + /// Explicitly defines a cell as a header cell. + /// + /// Header cells help users of Assistive Technology (AT) understand and navigate + /// complex tables. When your table is correctly marked up with header cells, AT + /// can announce the relevant header information on-demand when entering a cell. + /// + /// By default, Typst will automatically mark all cells within @table.header as + /// header cells. They will apply to the columns below them. You can use that + /// function's @table.header.level[`level`] parameter to make header cells + /// labelled by other header cells. + /// + /// The `pdf.header-cell` function allows you to indicate that a cell is a + /// header cell in the following additional situations: + /// + /// - You have a *header column* in which each cell applies to its row. In that + /// case, you pass `{"row"}` as an argument to the + /// @pdf.header-cell.scope[`scope` parameter] to indicate that the header cell + /// applies to the row. + /// - You have a cell in @table.header, for example at the very start, that + /// labels both its row and column. In that case, you pass `{"both"}` as an + /// argument to the @pdf.header-cell.scope[`scope`] parameter. + /// - You have a header cell in a row not containing other header cells. In that + /// case, you can use this function to mark it as a header cell. + /// + /// The API of this feature is temporary. Hence, calling this function requires + /// enabling the `a11y-extras` feature flag at the moment. In a future Typst + /// release, this functionality may move out of the `pdf` module so that tables + /// in other export targets can contain the same information. + /// + /// ```example + /// >>> #set text(font: "IBM Plex Sans") + /// #show table.cell.where(x: 0): set text(weight: "medium") + /// #show table.cell.where(y: 0): set text(weight: "bold") + /// + /// #table( + /// columns: 3, + /// align: (start, end, end), + /// + /// table.header( + /// // Top-left cell: Labels both the nutrient rows + /// // and the serving size columns. + /// pdf.header-cell(scope: "both")[Nutrient], + /// [Per 100g], + /// [Per Serving], + /// ), + /// + /// // First column cells are row headers + /// pdf.header-cell(scope: "row")[Calories], + /// [250 kcal], [375 kcal], + /// pdf.header-cell(scope: "row")[Protein], + /// [8g], [12g], + /// pdf.header-cell(scope: "row")[Fat], + /// [12g], [18g], + /// pdf.header-cell(scope: "row")[Carbs], + /// [30g], [45g], + /// ) + /// ``` + #[func(since = "0.14.0")] + #[feature = A11yExtras] + pub fn header_cell( + /// The nesting level of this header cell. + #[named] + #[default(NonZeroU32::ONE)] + level: NonZeroU32, + /// What track of the table this header cell applies to. + #[named] + #[default] + scope: TableHeaderScope, + /// The table cell. + /// + /// This can be content or a call to @table.cell. + cell: TableCell, + ) -> Content { + cell.with_kind(Smart::Custom(TableCellKind::Header(level, scope))) + .pack() + } + + /// Explicitly defines this cell as a data cell. + /// + /// Each cell in a table is either a header cell or a data cell. By default, all + /// cells in @table.header are header cells, and all other cells data cells. + /// + /// If your header contains a cell that is not a header cell, you can use this + /// function to mark it as a data cell. + /// + /// The API of this feature is temporary. Hence, calling this function requires + /// enabling the `a11y-extras` feature flag at the moment. In a future Typst + /// release, this functionality may move out of the `pdf` module so that tables + /// in other export targets can contain the same information. + /// + /// ```example + /// #show table.cell.where(x: 0): set text(weight: "bold") + /// #show table.cell.where(x: 1): set text(style: "italic") + /// #show table.cell.where(x: 1, y: 0): set text(style: "normal") + /// + /// #table( + /// columns: 3, + /// align: (left, left, center), + /// + /// table.header[Objective][Key Result][Status], + /// + /// table.header( + /// level: 2, + /// table.cell(colspan: 2)[Improve Customer Satisfaction], + /// // Status is data for this objective, not a header + /// pdf.data-cell[✓ On Track], + /// ), + /// [], [Increase NPS to 50+], [45], + /// [], [Reduce churn to \<5%], [4.2%], + /// + /// table.header( + /// level: 2, + /// table.cell(colspan: 2)[Grow Revenue], + /// pdf.data-cell[⚠ At Risk], + /// ), + /// [], [Achieve \$2M ARR], [\$1.8M], + /// [], [Close 50 enterprise deals], [38], + /// ) + /// ``` + #[func(since = "0.14.0")] + #[feature = A11yExtras] + pub fn data_cell( + /// The table cell. + /// + /// This can be content or a call to @table.cell. + cell: TableCell, + ) -> Content { + cell.with_kind(Smart::Custom(TableCellKind::Data)).pack() + } +} + +/// Encapsulates a list of compatible PDF standards. +#[derive(Clone, Eq, PartialEq, Hash)] +pub struct PdfStandards { + pub(crate) config: krilla::configure::Configuration, +} + +cast! { + PdfStandards, + self => { + Value::Array(Array::from_iter(self.standards().map(IntoValue::into_value))) + }, + standard: PdfStandard => PdfStandards::new([standard])?, + values: Array => { + let list = values.into_iter().map(Value::cast).collect::>>()?; + Self::new(list)? + } +} + +impl PdfStandards { + /// Validates a list of PDF standards for compatibility and returns their + /// encapsulated representation. + pub fn new>( + list: impl IntoIterator, + ) -> HintedStrResult { + use krilla::configure::{ + Accessibility, Archival, ConfigurationBuilder, ConfigurationError, PdfVersion, + }; + + use crate::util::ValidatorsExt; + + let mut version: Option = None; + let mut set_version = |v: PdfVersion| -> StrResult<()> { + if let Some(prev) = version { + bail!( + "PDF cannot conform to {} and {} at the same time", + prev.as_str(), + v.as_str(), + ); + } + version = Some(v); + Ok(()) + }; + + let mut archival_validator = None; + let mut set_archival_validator = |a: Archival| -> StrResult<()> { + if archival_validator.is_some() { + bail!("choose at most one PDF/A standard"); + } + archival_validator = Some(a); + Ok(()) + }; + + let mut accessibility_validator = None; + let mut set_accessibility_validator = |ua: Accessibility| -> StrResult<()> { + if accessibility_validator.is_some() { + bail!("choose at most one PDF/UA standard"); + } + accessibility_validator = Some(ua); + Ok(()) + }; + + for standard in list { + match standard.into() { + PdfStandard::V_1_4 => set_version(PdfVersion::Pdf14)?, + PdfStandard::V_1_5 => set_version(PdfVersion::Pdf15)?, + PdfStandard::V_1_6 => set_version(PdfVersion::Pdf16)?, + PdfStandard::V_1_7 => set_version(PdfVersion::Pdf17)?, + PdfStandard::V_2_0 => set_version(PdfVersion::Pdf20)?, + PdfStandard::A_1b => set_archival_validator(Archival::A1_B)?, + PdfStandard::A_1a => set_archival_validator(Archival::A1_A)?, + PdfStandard::A_2b => set_archival_validator(Archival::A2_B)?, + PdfStandard::A_2u => set_archival_validator(Archival::A2_U)?, + PdfStandard::A_2a => set_archival_validator(Archival::A2_A)?, + PdfStandard::A_3b => set_archival_validator(Archival::A3_B)?, + PdfStandard::A_3u => set_archival_validator(Archival::A3_U)?, + PdfStandard::A_3a => set_archival_validator(Archival::A3_A)?, + PdfStandard::A_4 => set_archival_validator(Archival::A4)?, + PdfStandard::A_4f => set_archival_validator(Archival::A4F)?, + PdfStandard::A_4e => set_archival_validator(Archival::A4E)?, + PdfStandard::UA_1 => set_accessibility_validator(Accessibility::UA1)?, + } + } + + let mut builder = ConfigurationBuilder::new(); + + if let Some(version) = version { + builder = builder.with_version(version); + } + + if let Some(archival_validator) = archival_validator { + builder = builder.with_archival_validator(archival_validator); + } + + if let Some(accessibility_validator) = accessibility_validator { + builder = builder.with_accessibility_validator(accessibility_validator); + } + + let config = builder.finish().map_err(|e| { + let (message, validators) = match e { + ConfigurationError::NoOverlappingValidatorsRange(validators) => { + let list = validators.to_and_list(); + let message = eco_format!( + "{list} are mutually incompatible because \ + they do not have any overlapping PDF versions" + ); + (message, validators) + } + ConfigurationError::VersionDoesNotMatchValidatorsRange( + version, + validators, + ) => { + let list = validators.to_and_list(); + let message = + eco_format!("{} is not compatible with {list}", version.as_str()); + (message, validators) + } + }; + HintedString::new(message) + .with_hints(validators.into_iter().map(version_hint)) + })?; + + Ok(Self { config }) + } + + /// Returns an iterator over PDF standards + pub fn standards(&self) -> impl Iterator { + std::iter::once(self.config.version().into()) + .chain(self.config.validators().into_iter().map(Into::into)) + } +} + +/// A hint specifying which PDF version a validator is compatible with. +fn version_hint(validator: krilla::configure::Validator) -> EcoString { + let min = validator.min(); + let max = validator.max(); + if let Some(min) = min { + if min == max { + eco_format!("{} requires version {}", validator.as_str(), min.as_str()) + } else { + eco_format!( + "{} requires a version between {} and {}", + validator.as_str(), + min.as_str(), + max.as_str() + ) + } + } else { + eco_format!("{} requires at least {}", validator.as_str(), max.as_str()) + } +} + +impl Debug for PdfStandards { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.pad("PdfStandards(..)") + } +} + +impl Default for PdfStandards { + fn default() -> Self { + use krilla::configure::{ConfigurationBuilder, PdfVersion}; + Self { + config: ConfigurationBuilder::new() + .with_version(PdfVersion::Pdf17) + .finish() + .unwrap(), + } + } +} + +/// A PDF standard that Typst can enforce conformance with. +/// +/// Support for more standards is planned. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast, Serialize, Deserialize)] +#[allow(non_camel_case_types)] +#[non_exhaustive] +pub enum PdfStandard { + /// PDF 1.4. + #[string("1.4")] + #[serde(rename = "1.4")] + V_1_4, + /// PDF 1.5. + #[string("1.5")] + #[serde(rename = "1.5")] + V_1_5, + /// PDF 1.6. + #[string("1.6")] + #[serde(rename = "1.6")] + V_1_6, + /// PDF 1.7. + #[string("1.7")] + #[serde(rename = "1.7")] + V_1_7, + /// PDF 2.0. + #[string("2.0")] + #[serde(rename = "2.0")] + V_2_0, + /// PDF/A-1b. + #[string("a-1b")] + #[serde(rename = "a-1b")] + A_1b, + /// PDF/A-1a. + #[string("a-1a")] + #[serde(rename = "a-1a")] + A_1a, + /// PDF/A-2b. + #[string("a-2b")] + #[serde(rename = "a-2b")] + A_2b, + /// PDF/A-2u. + #[string("a-2u")] + #[serde(rename = "a-2u")] + A_2u, + /// PDF/A-2a. + #[string("a-2a")] + #[serde(rename = "a-2a")] + A_2a, + /// PDF/A-3b. + #[string("a-3b")] + #[serde(rename = "a-3b")] + A_3b, + /// PDF/A-3u. + #[string("a-3u")] + #[serde(rename = "a-3u")] + A_3u, + /// PDF/A-3a. + #[string("a-3a")] + #[serde(rename = "a-3a")] + A_3a, + /// PDF/A-4. + #[string("a-4")] + #[serde(rename = "a-4")] + A_4, + /// PDF/A-4f. + #[string("a-4f")] + #[serde(rename = "a-4f")] + A_4f, + /// PDF/A-4e. + #[string("a-4e")] + #[serde(rename = "a-4e")] + A_4e, + /// PDF/UA-1. + #[string("ua-1")] + #[serde(rename = "ua-1")] + UA_1, +} + +impl From for PdfStandard { + fn from(value: PdfVersion) -> Self { + match value { + PdfVersion::Pdf14 => PdfStandard::V_1_4, + PdfVersion::Pdf15 => PdfStandard::V_1_5, + PdfVersion::Pdf16 => PdfStandard::V_1_6, + PdfVersion::Pdf17 => PdfStandard::V_1_7, + PdfVersion::Pdf20 => PdfStandard::V_2_0, + } + } +} + +impl From for PdfStandard { + fn from(value: Validator) -> Self { + match value { + Validator::A(archival) => match archival { + Archival::A1_A => PdfStandard::A_1a, + Archival::A1_B => PdfStandard::A_1b, + Archival::A2_A => PdfStandard::A_2a, + Archival::A2_B => PdfStandard::A_2b, + Archival::A2_U => PdfStandard::A_2u, + Archival::A3_A => PdfStandard::A_3a, + Archival::A3_B => PdfStandard::A_3b, + Archival::A3_U => PdfStandard::A_3u, + Archival::A4 => PdfStandard::A_4, + Archival::A4F => PdfStandard::A_4f, + Archival::A4E => PdfStandard::A_4e, + }, + Validator::Ua(accessibility) => match accessibility { + Accessibility::UA1 => PdfStandard::UA_1, + }, + } + } +} + +/// Document settings for PDF export. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct PdfFormatOptions { + /// Specifies which ranges of pages should be included in the PDF. When + /// `None`, all pages should be exported. + pub pages: F::Value, + /// A list of PDF standards that Typst will enforce conformance with. + pub standard: F::Value, + /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF + /// document is written to provide a baseline of accessibility. In some + /// circumstances, for example when trying to reduce the size of a document, + /// it can be desirable to disable tagged PDF. + pub tagged: F::Value, + /// Wether to format the PDF in a human readable way. + pub pretty: F::Value, +} + +impl Populate for PdfFormatOptions { + fn populate(&mut self, styles: Spanned) { + // VOLATILE: This must be updated when adding more fields. + self.pages.populate(styles); + self.standard.populate(styles); + self.tagged.populate(styles); + self.pretty.populate(styles); + } +} + +impl PdfFormatOptions { + /// Resolves the [`Partial`] options to [`Complete`] ones, given defaults. + pub fn resolve(&self, default: &PdfFormatOptions) -> PdfFormatOptions { + PdfFormatOptions { + pages: Partial::resolve_cloned(&self.pages, &default.pages), + standard: Partial::resolve_cloned(&self.standard, &default.standard), + tagged: Partial::resolve(self.tagged, default.tagged), + pretty: Partial::resolve(self.pretty, default.pretty), + } + } +} + +/// A file that will be attached to the output PDF. +/// +/// This can be used to distribute additional files associated with the PDF +/// within it. PDF readers will display the files in a file listing. +/// +/// Some international standards use this mechanism to attach machine-readable +/// data (e.g., ZUGFeRD/Factur-X for invoices) that mirrors the visual content +/// of the PDF. +/// +/// = Example +/// ```typ +/// #pdf.attach( +/// "experiment.csv", +/// relationship: "supplement", +/// mime-type: "text/csv", +/// description: "Raw Oxygen readings from the Arctic experiment", +/// ) +/// ``` +/// +/// = Notes +/// - This element is ignored if exporting to a format other than PDF. +/// - File attachments are not currently supported for PDF/A-2, even if the +/// attached file conforms to PDF/A-1 or PDF/A-2. +#[elem(since = "0.14.0", keywords = ["embed"], Locatable)] +pub struct AttachElem { + /// The path of the file to be attached. + /// + /// Must always be specified, but is only read from if no data is provided + /// in the following argument. + #[required] + #[parse( + let Spanned { v: path, span } = + args.expect::>("path")?; + let resolved = path.resolve_if_some(span.id()).at(span)?; + // The derived part is the virtual-root-relative resolved path. + let derived = resolved.vpath().get_without_slash().into(); + Derived::new(path, derived) + )] + pub path: Derived, + + /// Raw file data, optionally. + /// + /// If omitted, the data is read from the specified path. + #[positional] + // Not actually required as an argument, but always present as a field. + // We can't distinguish between the two at the moment. + #[required] + #[parse( + match args.eat::()? { + Some(data) => data, + None => engine.world.file(resolved.intern()).at(span)?, + } + )] + pub data: Bytes, + + /// The relationship of the attached file to the document. + /// + /// Ignored if export doesn't target PDF/A-3. + pub relationship: Option, + + /// The MIME type of the attached file. + // TODO: Use `krilla::embed::MimeType`, or a wrapper around it here. + // The problem is that it's currently completely opaque and doesn't allow + // retrieving the inner string after construction. + pub mime_type: Option, + + /// A description for the attached file. + pub description: Option, +} + +/// The relationship of an attached file with the document. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum AttachedFileRelationship { + /// The PDF document was created from the source file. + Source, + /// The file was used to derive a visual presentation in the PDF. + Data, + /// An alternative representation of the document. + Alternative, + /// Additional resources for the document. + Supplement, +} diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index 5666154f49e2..55a3552267e5 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -2,6 +2,7 @@ mod attach; mod convert; +mod format; mod image; mod link; mod metadata; @@ -13,28 +14,36 @@ mod tags; mod text; mod util; +pub use self::format::{ + AttachElem, AttachedFileRelationship, FORMAT, PdfFormat, PdfFormatOptions, + PdfStandard, PdfStandards, +}; pub use self::metadata::{Timestamp, Timezone}; -use std::fmt::{self, Debug, Formatter}; -use std::hash::{Hash, Hasher}; +use std::fmt::Debug; +use std::hash::Hash; use comemo::Tracked; -use ecow::{EcoString, eco_format}; -use krilla::configure::Accessibility; -use serde::{Deserialize, Serialize}; +use ecow::{EcoString, EcoVec}; +use krilla::configure::{PdfVersion, Validators}; use typst_layout::PagedDocument; -use typst_library::diag::{HintedStrResult, HintedString, SourceResult, StrResult, bail}; +use typst_library::diag::{SourceDiagnostic, SourceResult, Warned, bail}; +use typst_library::format::{Complete, Fields, Partial}; use typst_library::foundations::Smart; use typst_library::introspection::Location; -use typst_library::layout::PageRanges; use typst_library::model::LateLinkResolver; /// Export a document into a PDF file. /// /// Returns the raw bytes making up the PDF file. #[typst_macros::time(name = "pdf")] -pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult> { - convert::convert(document, options, &[], None) +pub fn pdf( + document: &PagedDocument, + options: &PdfOptions, +) -> Warned>> { + let mut warnings = EcoVec::new(); + let output = convert::convert(document, options, &[], None, &mut warnings); + Warned { output, warnings } } /// Export a document into a PDF file as part of a bundle. @@ -49,13 +58,16 @@ pub fn pdf_in_bundle( options: &PdfOptions, anchors: &[(Location, EcoString)], link_resolver: Tracked, -) -> SourceResult> { - convert::convert(document, options, anchors, Some(link_resolver)) +) -> Warned>> { + let mut warnings = EcoVec::new(); + let output = + convert::convert(document, options, anchors, Some(link_resolver), &mut warnings); + Warned { output, warnings } } /// Settings for PDF export. -#[derive(Debug, Hash)] -pub struct PdfOptions { +#[derive(Debug, Default, PartialEq, Eq, Hash)] +pub struct PdfOptions { /// If not `Smart::Auto`, shall be a string that uniquely and stably /// identifies the document. It should not change between compilations of /// the same document. **If you cannot provide such a stable identifier, @@ -74,258 +86,86 @@ pub struct PdfOptions { /// If not `None`, shall be the creation timestamp of the document. It will /// only be used if `set document(date: ..)` is `auto`. pub timestamp: Option, - /// Specifies which ranges of pages should be exported in the PDF. When - /// `None`, all pages should be exported. - pub page_ranges: Option, - /// A list of PDF standards that Typst will enforce conformance with. - pub standards: PdfStandards, - /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF - /// document is written to provide a baseline of accessibility. In some - /// circumstances, for example when trying to reduce the size of a document, - /// it can be desirable to disable tagged PDF. - pub tagged: bool, - /// Whether to format the PDF in a human-readable way. - pub pretty: bool, + /// Format options that override the defaults set by the document. + pub format: PdfFormatOptions, } impl PdfOptions { - /// Returns the accessibility validator. Returns `Some` for PDF/UA-1, and in - /// the future maybe PDF/UA-2. - pub(crate) fn accessibility_validator(&self) -> Option { - self.standards.config.validators().accessibility() - } -} - -impl Default for PdfOptions { - fn default() -> Self { - Self { - ident: Smart::Auto, - creator: Smart::Auto, - timestamp: None, - page_ranges: None, - standards: PdfStandards::default(), - tagged: true, - pretty: false, - } - } -} - -/// Encapsulates a list of compatible PDF standards. -#[derive(Clone)] -pub struct PdfStandards { - pub(crate) config: krilla::configure::Configuration, -} - -impl PdfStandards { - /// Validates a list of PDF standards for compatibility and returns their - /// encapsulated representation. - pub fn new(list: &[PdfStandard]) -> HintedStrResult { - use krilla::configure::{ - Accessibility, Archival, ConfigurationBuilder, ConfigurationError, PdfVersion, - }; - - use crate::util::ValidatorsExt; - - let mut version: Option = None; - let mut set_version = |v: PdfVersion| -> StrResult<()> { - if let Some(prev) = version { - bail!( - "PDF cannot conform to {} and {} at the same time", - prev.as_str(), - v.as_str(), + /// Resolves the final options given those set by the document. + pub fn resolve( + &self, + doc: &PdfFormatOptions, + warnings: &mut EcoVec, + ) -> SourceResult> { + let format = self.format.resolve(doc); + + if format.pages.v.is_some() { + if format.tagged.v == Smart::Custom(true) { + let span = format.tagged.span.or(format.pages.span); + bail!(span, "cannot enable tagged PDF and export a page range"); + } else if format.tagged.v.is_auto() { + warnings.push( + SourceDiagnostic::warning( + format.tagged.span, + "exporting a page range disables PDF tagging", + ) + .with_hints([ + "the resulting PDF will be inaccessible".into(), + "set `pdf(tagged: false)` to silence this warning".into(), + ]), ); } - version = Some(v); - Ok(()) - }; - - let mut archival_validator = None; - let mut set_archival_validator = |a: Archival| -> StrResult<()> { - if archival_validator.is_some() { - bail!("choose at most one PDF/A standard"); - } - archival_validator = Some(a); - Ok(()) - }; - - let mut accessibility_validator = None; - let mut set_accessibility_validator = |ua: Accessibility| -> StrResult<()> { - if accessibility_validator.is_some() { - bail!("choose at most one PDF/UA standard"); - } - accessibility_validator = Some(ua); - Ok(()) - }; - - for standard in list { - match standard { - PdfStandard::V_1_4 => set_version(PdfVersion::Pdf14)?, - PdfStandard::V_1_5 => set_version(PdfVersion::Pdf15)?, - PdfStandard::V_1_6 => set_version(PdfVersion::Pdf16)?, - PdfStandard::V_1_7 => set_version(PdfVersion::Pdf17)?, - PdfStandard::V_2_0 => set_version(PdfVersion::Pdf20)?, - PdfStandard::A_1b => set_archival_validator(Archival::A1_B)?, - PdfStandard::A_1a => set_archival_validator(Archival::A1_A)?, - PdfStandard::A_2b => set_archival_validator(Archival::A2_B)?, - PdfStandard::A_2u => set_archival_validator(Archival::A2_U)?, - PdfStandard::A_2a => set_archival_validator(Archival::A2_A)?, - PdfStandard::A_3b => set_archival_validator(Archival::A3_B)?, - PdfStandard::A_3u => set_archival_validator(Archival::A3_U)?, - PdfStandard::A_3a => set_archival_validator(Archival::A3_A)?, - PdfStandard::A_4 => set_archival_validator(Archival::A4)?, - PdfStandard::A_4f => set_archival_validator(Archival::A4F)?, - PdfStandard::A_4e => set_archival_validator(Archival::A4E)?, - PdfStandard::Ua_1 => set_accessibility_validator(Accessibility::UA1)?, - } } - let mut builder = ConfigurationBuilder::new(); - - if let Some(version) = version { - builder = builder.with_version(version); - } - - if let Some(archival_validator) = archival_validator { - builder = builder.with_archival_validator(archival_validator); - } - - if let Some(accessibility_validator) = accessibility_validator { - builder = builder.with_accessibility_validator(accessibility_validator); - } - - let config = builder.finish().map_err(|e| { - let (message, validators) = match e { - ConfigurationError::NoOverlappingValidatorsRange(validators) => { - let list = validators.to_and_list(); - let message = eco_format!( - "{list} are mutually incompatible because \ - they do not have any overlapping PDF versions" + const ACCESSIBLE: [(PdfStandard, &str); 4] = [ + (PdfStandard::A_1a, "PDF/A-1a"), + (PdfStandard::A_2a, "PDF/A-2a"), + (PdfStandard::A_3a, "PDF/A-3a"), + (PdfStandard::UA_1, "PDF/UA-1"), + ]; + for (standard, name) in ACCESSIBLE { + if format.standard.v.standards().any(|s| s == standard) { + if format.tagged.v == Smart::Custom(false) { + let span = format.standard.span.or(format.tagged.span); + bail!( + span, + "cannot set `pdf(tagged: false)` when exporting a {name} document" + ); + } else if format.pages.v.is_some() { + let span = format.standard.span.or(format.pages.span); + bail!( + span, + "cannot set `pdf(tagged: false)` when exporting a {name} document"; + hint: "using `pdf(pages: ...)` implies `pdf(tagged: false)`"; ); - (message, validators) - } - ConfigurationError::VersionDoesNotMatchValidatorsRange( - version, - validators, - ) => { - let list = validators.to_and_list(); - let message = - eco_format!("{} is not compatible with {list}", version.as_str()); - (message, validators) } - }; - HintedString::new(message) - .with_hints(validators.into_iter().map(version_hint)) - })?; - - Ok(Self { config }) - } -} - -/// A hint specifying which PDF version a validator is compatible with. -fn version_hint(validator: krilla::configure::Validator) -> EcoString { - let min = validator.min(); - let max = validator.max(); - if let Some(min) = min { - if min == max { - eco_format!("{} requires version {}", validator.as_str(), min.as_str()) - } else { - eco_format!( - "{} requires a version between {} and {}", - validator.as_str(), - min.as_str(), - max.as_str() - ) + } } - } else { - eco_format!("{} requires at least {}", validator.as_str(), max.as_str()) - } -} -impl Debug for PdfStandards { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.pad("PdfStandards(..)") + Ok(PdfOptions { + ident: self.ident.clone(), + creator: self.creator.clone(), + timestamp: self.timestamp, + format, + }) } } -impl Default for PdfStandards { - fn default() -> Self { - use krilla::configure::{ConfigurationBuilder, PdfVersion}; - Self { - config: ConfigurationBuilder::new() - .with_version(PdfVersion::Pdf17) - .finish() - .unwrap(), - } +impl PdfOptions { + pub(crate) fn version(&self) -> PdfVersion { + self.format.standard.v.config.version() } -} -// Could be turned into a derive if krilla's `Configuration` ever implements -// `Hash`. -impl Hash for PdfStandards { - fn hash(&self, state: &mut H) { - (self.config.version() as usize).hash(state); - for validator in self.config.validators() { - validator.hash(state); - } + /// Returns whether to produce a tagged PDF document. + /// By default unless explicitly disabled or when only exporting a page + /// range, tagging is enabled. + pub(crate) fn tagged(&self) -> bool { + self.format.pages.v.is_none() && self.format.tagged.v.unwrap_or(true) } -} -/// A PDF standard that Typst can enforce conformance with. -/// -/// Support for more standards is planned. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)] -#[expect(non_camel_case_types)] -#[non_exhaustive] -pub enum PdfStandard { - /// PDF 1.4. - #[serde(rename = "1.4")] - V_1_4, - /// PDF 1.5. - #[serde(rename = "1.5")] - V_1_5, - /// PDF 1.5. - #[serde(rename = "1.6")] - V_1_6, - /// PDF 1.7. - #[serde(rename = "1.7")] - V_1_7, - /// PDF 2.0. - #[serde(rename = "2.0")] - V_2_0, - /// PDF/A-1b. - #[serde(rename = "a-1b")] - A_1b, - /// PDF/A-1a. - #[serde(rename = "a-1a")] - A_1a, - /// PDF/A-2b. - #[serde(rename = "a-2b")] - A_2b, - /// PDF/A-2u. - #[serde(rename = "a-2u")] - A_2u, - /// PDF/A-2a. - #[serde(rename = "a-2a")] - A_2a, - /// PDF/A-3b. - #[serde(rename = "a-3b")] - A_3b, - /// PDF/A-3u. - #[serde(rename = "a-3u")] - A_3u, - /// PDF/A-3a. - #[serde(rename = "a-3a")] - A_3a, - /// PDF/A-4. - #[serde(rename = "a-4")] - A_4, - /// PDF/A-4f. - #[serde(rename = "a-4f")] - A_4f, - /// PDF/A-4e. - #[serde(rename = "a-4e")] - A_4e, - /// PDF/UA-1. - #[serde(rename = "ua-1")] - Ua_1, + /// Returns the accessibility validator. Returns `Some` for PDF/UA-1, and in + /// the future maybe PDF/UA-2. + pub(crate) fn validators(&self) -> Validators { + self.format.standard.v.config.validators() + } } diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index 468a1430c011..8127a3e7666f 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -78,7 +78,7 @@ pub(crate) fn handle_link( if tags::disabled(gc) { if gc.tags.in_tiling - && let Some(accessibility) = gc.options.accessibility_validator() + && let Some(accessibility) = gc.options.validators().accessibility() { let validator = accessibility.as_str(); bail!( @@ -109,7 +109,7 @@ pub(crate) fn handle_link( let alt = link.alt.as_ref().map(Into::into); if gc.tags.tree.parent_artifact().is_some() { - if let Some(accessibility) = gc.options.accessibility_validator() { + if let Some(accessibility) = gc.options.validators().accessibility() { let validator = accessibility.as_str(); bail!( link.span(), @@ -142,7 +142,7 @@ pub(crate) fn handle_link( // typst/typst and then ends on another line. // ``` // The bounding box would span the entire paragraph, which is undesirable. - let join_annotations = gc.options.accessibility_validator().is_some(); + let join_annotations = gc.options.validators().accessibility().is_some(); match fc.get_link_annotation(group_id) { Some(annotation) if join_annotations => annotation.rects.push(rect), _ => { diff --git a/crates/typst-pdf/src/metadata.rs b/crates/typst-pdf/src/metadata.rs index e17533c6486a..ea627646cebf 100644 --- a/crates/typst-pdf/src/metadata.rs +++ b/crates/typst-pdf/src/metadata.rs @@ -99,7 +99,7 @@ pub fn creation_date(gc: &GlobalContext) -> Option { } /// A timestamp with timezone information. -#[derive(Debug, Copy, Clone, Hash)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Timestamp { /// The datetime of the timestamp. pub(crate) datetime: Datetime, diff --git a/crates/typst-pdf/src/outline.rs b/crates/typst-pdf/src/outline.rs index ee935b973174..aabaa700a6fb 100644 --- a/crates/typst-pdf/src/outline.rs +++ b/crates/typst-pdf/src/outline.rs @@ -22,7 +22,7 @@ pub(crate) fn build_outline(gc: &GlobalContext) -> KrillaOutline { .get(StyleChain::default()) .unwrap_or_else(|| heading.outlined.get(StyleChain::default())); - let visible = gc.options.page_ranges.as_ref().is_none_or(|ranges| { + let visible = gc.options.format.pages.v.as_ref().is_none_or(|ranges| { !ranges.includes_page( gc.document .introspector() diff --git a/crates/typst-pdf/src/paint.rs b/crates/typst-pdf/src/paint.rs index 644687164656..5444f09e834d 100644 --- a/crates/typst-pdf/src/paint.rs +++ b/crates/typst-pdf/src/paint.rs @@ -170,7 +170,7 @@ fn convert_pattern( state: &State, ) -> SourceResult<(krilla::paint::Paint, u8)> { let transform = correct_transform(state, pattern.unwrap_relative(on_text)) - .pre_concat(Transform::translate(pattern.offset().x, pattern.offset().y)); + .pre_concat(pattern.transform()); let mut stream_builder = surface.stream_builder(); let mut surface = stream_builder.surface(); diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs index 31c5776782a4..177d6b8f6fe8 100644 --- a/crates/typst-pdf/src/shape.rs +++ b/crates/typst-pdf/src/shape.rs @@ -44,9 +44,8 @@ pub(crate) fn handle_shape( None }; - let stroke = shape.stroke.as_ref().and_then(|stroke| { - if stroke.thickness.to_f32() > 0.0 { Some(stroke) } else { None } - }); + let stroke = + shape.stroke.as_ref().filter(|stroke| stroke.thickness.to_f32() > 0.0); let stroke = if let Some(stroke) = &stroke { let stroke = paint::convert_stroke( diff --git a/crates/typst-pdf/src/tags/context/table.rs b/crates/typst-pdf/src/tags/context/table.rs index db06b9492279..7212a15454ab 100644 --- a/crates/typst-pdf/src/tags/context/table.rs +++ b/crates/typst-pdf/src/tags/context/table.rs @@ -11,8 +11,7 @@ use smallvec::SmallVec; use typst_library::foundations::Packed; use typst_library::layout::resolve::{CellGrid, Line, LinePosition}; use typst_library::layout::{Abs, Sides}; -use typst_library::model::{TableCell, TableElem}; -use typst_library::pdf::{TableCellKind, TableHeaderScope}; +use typst_library::model::{TableCell, TableCellKind, TableElem, TableHeaderScope}; use typst_library::visualize::{FixedStroke, Stroke}; use crate::tags::GroupId; diff --git a/crates/typst-pdf/src/tags/groups.rs b/crates/typst-pdf/src/tags/groups.rs index c1d68461314e..49c55e4821a7 100644 --- a/crates/typst-pdf/src/tags/groups.rs +++ b/crates/typst-pdf/src/tags/groups.rs @@ -4,6 +4,7 @@ use krilla::configure::PdfVersion; use krilla::geom as kg; use krilla::tagging::{Artifact, ArtifactType, Identifier, ListNumbering, TagKind}; use rustc_hash::FxHashMap; +use typst_library::format::Complete; use typst_library::foundations::{Content, Packed}; use typst_library::introspection::Location; use typst_library::layout::{GridCell, Inherit, Size}; @@ -504,13 +505,12 @@ impl GroupKind { pub fn to_artifact( &self, - options: &PdfOptions, + options: &PdfOptions, page_size: Option, ) -> Option { let GroupKind::Artifact(artifact) = *self else { return None }; - if options.standards.config.version() == PdfVersion::Pdf17 - && artifact == ArtifactType::Background + if options.version() == PdfVersion::Pdf17 && artifact == ArtifactType::Background { // Background artifacts were introduced in PDF 1.7. However, // they require a bounding box there, which may not be readily diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index d7de9fad2667..148a115bbb33 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -4,11 +4,11 @@ use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag}; use typst_layout::PagedDocument; -use typst_library::diag::{SourceResult, bail}; +use typst_library::diag::SourceResult; +use typst_library::format::Complete; use typst_library::layout::{FrameParent, Point, Rect, Size}; use typst_library::text::{Locale, TextItem}; use typst_library::visualize::{Image, Shape}; -use typst_syntax::Span; use crate::PdfOptions; use crate::convert::{FrameContext, GlobalContext}; @@ -25,12 +25,11 @@ mod resolve; mod tree; mod util; -pub fn init(document: &PagedDocument, options: &PdfOptions) -> SourceResult { - let tree = if options.tagged { - if options.page_ranges.is_some() { - bail!(Span::detached(), "cannot enable tagged PDF and export a page range"); - } - +pub fn init( + document: &PagedDocument, + options: &PdfOptions, +) -> SourceResult { + let tree = if options.tagged() { tree::build(document, options)? } else { Tree::empty(document, options) @@ -118,9 +117,7 @@ pub fn tiling( tiling_size.y.to_pt() as f32, ); surface.start_tagged(ContentTag::Artifact(Artifact::new( - if gc.options.standards.config.version() == PdfVersion::Pdf17 - && bbox.is_none() - { + if gc.options.version() == PdfVersion::Pdf17 && bbox.is_none() { // PDF 1.7 cannot tolerate empty bounding boxes for background // artifacts. ArtifactType::Other @@ -142,10 +139,10 @@ pub fn tiling( } /// Whether tag generation is currently disabled. Either because it has been -/// disabled by the user using the [`PdfOptions::tagged`] flag, or we're inside -/// a tiling. +/// disabled by the user using the [`crate::PdfFormatOptions::tagged`] flag, or +/// we're inside a tiling. pub fn disabled(gc: &GlobalContext) -> bool { - !gc.options.tagged || gc.tags.in_tiling + !gc.options.tagged() || gc.tags.in_tiling } /// Add all annotations that were found in the page frame. @@ -269,7 +266,7 @@ pub fn shape<'a, 'b>( } surface.start_tagged(ContentTag::Artifact(Artifact::with_kind( - if gc.options.standards.config.version() == PdfVersion::Pdf17 + if gc.options.version() == PdfVersion::Pdf17 && artifact_type == ArtifactType::Background { ArtifactType::Other @@ -287,36 +284,8 @@ fn update_bbox( compute_bbox: impl FnOnce() -> Rect, ) { if let Some(bbox) = gc.tags.tree.parent_bbox() - && gc.options.standards.config.validators().accessibility().is_some() + && gc.options.validators().accessibility().is_some() { bbox.expand_frame(fc, compute_bbox); } } - -#[cfg(test)] -mod tests { - use std::num::NonZeroUsize; - - use ecow::EcoVec; - use typst_layout::PagedDocument; - use typst_library::layout::PageRanges; - use typst_library::model::DocumentInfo; - use typst_utils::NonZeroExt; - - use crate::tags; - - #[test] - fn tagged_and_page_range() { - let options = crate::PdfOptions { - page_ranges: Some(PageRanges::new(vec![Some(NonZeroUsize::ONE)..=None])), - ..Default::default() - }; - let document = PagedDocument::new(EcoVec::new(), DocumentInfo::default()); - let res = tags::init(&document, &options); - - assert_eq!( - res.err().unwrap().first().unwrap().message, - "cannot enable tagged PDF and export a page range" - ); - } -} diff --git a/crates/typst-pdf/src/tags/resolve/mod.rs b/crates/typst-pdf/src/tags/resolve/mod.rs index 4543bec912b2..527be87f1741 100644 --- a/crates/typst-pdf/src/tags/resolve/mod.rs +++ b/crates/typst-pdf/src/tags/resolve/mod.rs @@ -5,6 +5,7 @@ use krilla::tagging::{self as kt, Node, Tag, TagKind}; use krilla::tagging::{Identifier, TagTree}; use smallvec::SmallVec; use typst_library::diag::{At, SourceDiagnostic, SourceResult, error}; +use typst_library::format::Complete; use typst_library::text::Locale; use typst_syntax::Span; @@ -33,7 +34,7 @@ pub enum TagNode { } struct Resolver<'a> { - options: &'a PdfOptions, + options: &'a PdfOptions, ctx: &'a Ctx, groups: &'a IdVec, tags: &'a mut TagStorage, @@ -192,7 +193,7 @@ fn resolve_group_node( } } - if rs.options.accessibility_validator().is_some() { + if rs.options.validators().accessibility().is_some() { validate_children(rs, &tag, &nodes); } @@ -326,7 +327,7 @@ fn build_group_tag(rs: &mut Resolver, group: &Group) -> Option { if let TagKind::Hn(tag) = &tag { let prev_level = rs.last_heading_level.map_or(0, |l| l.get()); let next_level = tag.level(); - if let Some(accessibility) = rs.options.accessibility_validator() + if let Some(accessibility) = rs.options.validators().accessibility() && next_level.get().saturating_sub(prev_level) > 1 { let span = to_span(tag.as_any().location); @@ -467,7 +468,7 @@ fn validate_children_groups( }; if !is_valid(&child.tag) { - let validator = rs.options.standards.config.validators().to_comma_list(); + let validator = rs.options.validators().to_comma_list(); let span = to_span(child.tag.location()).or(parent_span); let parent = tag_name(parent); let child = tag_name(&child.tag); @@ -483,7 +484,7 @@ fn validate_children_groups( } if caption_spans.len() > 1 { - let validator = rs.options.standards.config.validators().to_comma_list(); + let validator = rs.options.validators().to_comma_list(); let parent = tag_name(parent); let child = tag_name(&Tag::Caption.into()); @@ -503,7 +504,7 @@ fn validate_children_groups( } if contains_leaf_nodes { - let validator = rs.options.standards.config.validators().to_comma_list(); + let validator = rs.options.validators().to_comma_list(); let parent = tag_name(parent); rs.errors.push(error!( parent_span, diff --git a/crates/typst-pdf/src/tags/tree/build.rs b/crates/typst-pdf/src/tags/tree/build.rs index 5e54c621762f..57a1ab01f905 100644 --- a/crates/typst-pdf/src/tags/tree/build.rs +++ b/crates/typst-pdf/src/tags/tree/build.rs @@ -24,6 +24,7 @@ use typst_library::diag::{ At, ExpectInternal, SourceDiagnostic, SourceResult, assert_internal, bail, error, panic_internal, }; +use typst_library::format::Complete; use typst_library::foundations::{Content, ContextElem}; use typst_library::introspection::Location; use typst_library::layout::{ @@ -32,11 +33,11 @@ use typst_library::layout::{ }; use typst_library::math::EquationElem; use typst_library::model::{ - Document, EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry, - HeadingElem, LinkMarker, ListElem, Outlinable, OutlineEntry, ParElem, QuoteElem, - StrongElem, TableCell, TableElem, TermsElem, TitleElem, + ArtifactElem, Document, EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteElem, + FootnoteEntry, HeadingElem, LinkMarker, ListElem, Outlinable, OutlineEntry, ParElem, + PdfMarkerTag, PdfMarkerTagKind, QuoteElem, StrongElem, TableCell, TableElem, + TermsElem, TitleElem, }; -use typst_library::pdf::{ArtifactElem, PdfMarkerTag, PdfMarkerTagKind}; use typst_library::text::{ HighlightElem, OverlineElem, RawElem, RawLine, StrikeElem, SubElem, SuperElem, UnderlineElem, @@ -54,7 +55,7 @@ use crate::tags::util::{ArtifactKindExt, PropertyValCopied}; use crate::util::ValidatorsExt; pub struct TreeBuilder<'a> { - options: &'a PdfOptions, + options: &'a PdfOptions, /// Each [`FrameItem::Tag`] and each [`FrameItem::Group`] with a parent /// will append a progression to this tree. This list of progressions is @@ -76,7 +77,7 @@ pub struct TreeBuilder<'a> { } impl<'a> TreeBuilder<'a> { - pub fn new(document: &PagedDocument, options: &'a PdfOptions) -> Self { + pub fn new(document: &PagedDocument, options: &'a PdfOptions) -> Self { let doc_lang = document.info().locale.custom(); let mut groups = Groups::new(); let doc = groups.new_virtual( @@ -177,7 +178,10 @@ struct StackEntry { prog_idx: u32, } -pub fn build(document: &PagedDocument, options: &PdfOptions) -> SourceResult { +pub fn build( + document: &PagedDocument, + options: &PdfOptions, +) -> SourceResult { let mut tree = TreeBuilder::new(document, options); for page in document.pages() { visit_frame(&mut tree, &page.frame)?; @@ -202,7 +206,7 @@ pub fn build(document: &PagedDocument, options: &PdfOptions) -> SourceResult GroupId { let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); let title = heading.body.plain_text().to_string(); if title.is_empty() - && let Some(accessibility) = tree.options.accessibility_validator() + && let Some(accessibility) = tree.options.validators().accessibility() { let contains_context = heading.body.traverse(&mut |c| { if c.is::() { @@ -606,7 +610,7 @@ fn progress_tree_end(tree: &mut TreeBuilder, loc: Location) -> SourceResult SourceResult Self { + pub fn empty(document: &PagedDocument, options: &PdfOptions) -> Self { TreeBuilder::new(document, options).finish() } @@ -336,7 +337,7 @@ fn open_group( groups: &Groups, state: &mut TraversalState, fc: &FrameContext, - options: &PdfOptions, + options: &PdfOptions, surface: &mut Surface, id: GroupId, ) { @@ -360,7 +361,7 @@ fn open_group( fn open_multiple_groups<'a>( state: &mut TraversalState, fc: &FrameContext, - options: &PdfOptions, + options: &PdfOptions, surface: &mut Surface, rev_iter: impl Iterator, ) { diff --git a/crates/typst-pdf/src/tags/tree/text.rs b/crates/typst-pdf/src/tags/tree/text.rs index d8ec98381b72..25c98be3b6b7 100644 --- a/crates/typst-pdf/src/tags/tree/text.rs +++ b/crates/typst-pdf/src/tags/tree/text.rs @@ -1,5 +1,6 @@ use krilla::tagging::{LineHeight, NaiveRgbColor, TextDecorationType}; use typst_library::diag::{SourceDiagnostic, error}; +use typst_library::format::Complete; use typst_library::foundations::{Content, Packed, Smart}; use typst_library::layout::Length; use typst_library::text::{ @@ -125,7 +126,7 @@ impl TextParams { pub fn resolve_text_attrs( tree: &mut Tree, - options: &PdfOptions, + options: &PdfOptions, text: &TextItem, ) -> ResolvedTextAttrs { let params = TextParams::new(text); @@ -144,7 +145,7 @@ pub fn resolve_text_attrs( } fn compute_attrs( - options: &PdfOptions, + options: &PdfOptions, items: &[(GroupId, TextAttr)], text: &TextItem, ) -> (ResolvedTextAttrs, Option) { @@ -245,7 +246,7 @@ fn compute_script( fn compute_deco<'a>( resolved: &mut Option<(&'a Content, ResolvedTextDeco)>, err: &mut Option, - options: &PdfOptions, + options: &PdfOptions, text: &TextItem, elem: &'a Content, kind: TextDecoKind, @@ -257,7 +258,7 @@ fn compute_deco<'a>( // If PDF/UA-1 is enforced, throw an error. if err.is_none() && deco.kind != kind - && let Some(accessibility) = options.accessibility_validator() + && let Some(accessibility) = options.validators().accessibility() { let validator = accessibility.as_str(); let span = elem.span(); diff --git a/crates/typst-pdf/src/tags/util/mod.rs b/crates/typst-pdf/src/tags/util/mod.rs index d2064876cbe1..7fb673bac0ac 100644 --- a/crates/typst-pdf/src/tags/util/mod.rs +++ b/crates/typst-pdf/src/tags/util/mod.rs @@ -1,6 +1,6 @@ use krilla::tagging as kt; use krilla::tagging::{ArtifactType, NaiveRgbColor}; -use typst_library::pdf::{ArtifactKind, TableHeaderScope}; +use typst_library::model::{ArtifactKind, TableHeaderScope}; use typst_library::text::Locale; use typst_library::visualize::Paint; diff --git a/crates/typst-realize/Cargo.toml b/crates/typst-realize/Cargo.toml index 684d3e3daae2..619097683e4d 100644 --- a/crates/typst-realize/Cargo.toml +++ b/crates/typst-realize/Cargo.toml @@ -21,6 +21,7 @@ typst-timing = { workspace = true } typst-utils = { workspace = true } arrayvec = { workspace = true } bumpalo = { workspace = true } +codex = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } regex = { workspace = true } diff --git a/crates/typst-realize/src/lib.rs b/crates/typst-realize/src/lib.rs index 00eb25b8e875..9f1a77f8bf31 100644 --- a/crates/typst-realize/src/lib.rs +++ b/crates/typst-realize/src/lib.rs @@ -19,9 +19,9 @@ use typst_library::World; use typst_library::diag::{At, SourceResult, bail, warning}; use typst_library::engine::Engine; use typst_library::foundations::{ - Content, Context, ContextElem, Element, NativeElement, NativeShowRule, Packed, - Recipe, RecipeIndex, Selector, SequenceElem, ShowSet, Style, StyleChain, StyledElem, - Styles, SymbolElem, Synthesize, Target, TargetElem, Transformation, + Chainable, Content, Context, ContextElem, Element, NativeElement, NativeShowRule, + Packed, Recipe, RecipeIndex, Selector, SequenceElem, ShowSet, Style, StyleChain, + StyledElem, Styles, SymbolElem, Synthesize, Target, TargetElem, Transformation, }; use typst_library::introspection::{LocationKey, SplitLocator, Tag, TagElem, TagFlags}; use typst_library::layout::{ @@ -34,11 +34,11 @@ use typst_library::model::{ }; use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind}; use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; -use typst_syntax::Span; +use typst_syntax::{Span, Spanned}; use typst_utils::{ListSet, SliceExt, SmallBitSet}; mod spaces; -use spaces::{SpaceState, collapse_spaces, collapse_state_textual}; +use spaces::{SpaceAction, SpaceState, collapse_spaces, collapse_transition_textual}; /// Realize content into a flat list of well-known, styled items. #[typst_macros::time(name = "realize")] @@ -604,12 +604,17 @@ fn visit_styled<'a>( // Check for document and page styles. let mut pagebreak = false; + let mut populated_document_info = false; + let mut populated_format_options = false; + let full = local.chain(&outer); for style in local.iter() { let Some(elem) = style.element() else { continue }; if elem == DocumentElem::ELEM { - let local = StyleChain::new(&local); - if let RealizationKind::Document { info } = &mut s.kind { - info.populate(local); + if let RealizationKind::Document { info, .. } = &mut s.kind { + if !populated_document_info { + info.populate(full); + populated_document_info = true; + } } else if !matches!(s.kind, RealizationKind::Bundle) { bail!( style.span(), @@ -626,8 +631,8 @@ fn visit_styled<'a>( } } else if elem == TextElem::ELEM { // Infer the document locale from the first toplevel set rule. - if let RealizationKind::Document { info } = &mut s.kind { - info.populate_locale(StyleChain::new(&local)); + if let RealizationKind::Document { info, .. } = &mut s.kind { + info.populate_locale(full); } } else if elem == PageElem::ELEM { match s.kind { @@ -652,6 +657,18 @@ fn visit_styled<'a>( "page configuration is not allowed inside of containers", ), } + } else if s.engine.library.formats.iter().any(|f| f.elem == elem) { + if let RealizationKind::Document { options, .. } = &mut s.kind { + if !populated_format_options { + options.populate(Spanned::new(full, style.span())); + populated_format_options = true; + } + } else if !matches!(s.kind, RealizationKind::Bundle) { + bail!( + style.span(), + "format set rules are not allowed inside of containers", + ); + } } } @@ -1285,23 +1302,16 @@ fn find_regex_match_in_elems<'a>( let mut state = SpaceState::Destructive; for &(content, styles) in elems { - let (new_state, text) = collapse_state_textual(content, styles); - state = match new_state { - SpaceState::Invisible => continue, - SpaceState::Destructive => { - if state == SpaceState::Space { - buf.pop(); - } - SpaceState::Destructive - } - SpaceState::Supportive => SpaceState::Supportive, - SpaceState::Space => { - if state != SpaceState::Supportive { - continue; - } - SpaceState::Space + let (action, text); + (action, state, text) = collapse_transition_textual(state, content, styles); + match action { + SpaceAction::Invisible => continue, + SpaceAction::Skip => continue, + SpaceAction::Discard => { + buf.pop(); } - }; + SpaceAction::Keep => {} + } // If styles differ, we search _before_ adding the new element's text. if styles != current && !buf.is_empty() { diff --git a/crates/typst-realize/src/spaces.rs b/crates/typst-realize/src/spaces.rs index 153c22e66390..eb7ca52573e6 100644 --- a/crates/typst-realize/src/spaces.rs +++ b/crates/typst-realize/src/spaces.rs @@ -1,5 +1,6 @@ -//! The space collapsing infrastructure for realization. +//! The space collapsing and discarding infrastructure for realization. +use codex::space_discarding::discard_space_between; use typst_html::HtmlElem; use typst_library::foundations::{Content, StyleChain}; use typst_library::introspection::TagElem; @@ -7,17 +8,50 @@ use typst_library::layout::HElem; use typst_library::routines::Pair; use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; -/// State kept for space collapsing. +/// State kept for space collapsing/discarding. #[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub(crate) enum SpaceState { - /// Invisible elements do not impact space collapsing. - Invisible, - /// Destructive elements discard spaces that come before or after. +pub(crate) enum SpaceState<'a> { + /// When destructive, we skip any future spaces. Destructive, - /// Normal elements. Spaces are only kept if supported on both sides. - Supportive, - /// Adjacent spaces collapse as one with the styles of the first space. - Space, + /// When supportive, we usually keep future spaces, but we will discard + /// newline spaces if the text surrounding a newline space returns true for + /// [`discard_space_between`]. + /// + /// Text can be empty if this is not a textual element. This is easier than + /// using an `Option`. + Supportive { text: &'a str }, + /// A current space. Spaces collapse into one with the styles of the first + /// space by skipping following spaces, and they set `had_newline` to true + /// if any space in a group had a newline. + /// + /// Since `had_newline` may update based on a future space, all spaces need + /// to remember the previous text of a supportive element to do the + /// [`discard_space_between`] check. + /// + /// The `had_newline` grouping is done so that spaces before line comments + /// don't add new space. E.g. `x //\n` + Space { prev_text: &'a str, had_newline: bool }, +} + +/// What action to take for space collapsing/discarding. +/// +/// This is in addition to updating the [`SpaceState`] itself, which is +/// necessary even when the action is `Skip`. +#[derive(Debug, Copy, Clone)] +pub(crate) enum SpaceAction { + /// Invisible elements are themselves kept, but neither contain text nor + /// affect the space collapsing state. + Invisible, + /// Avoid adding the current space element. + Skip, + /// Discard the preceding space, but keep the current element. + /// + /// This is not returned unless there was a preceding space. + Discard, + /// Keep the current element and don't change any preceding spaces (if any). + /// + /// This is given for destructive elements that weren't preceded by a space. + Keep, } /// Run the space collapsing algorithm on `buf[start..]`. This discards space @@ -39,24 +73,19 @@ pub(crate) fn collapse_spaces(buf: &mut Vec, start: usize) { for i in start..buf.len() { let (content, styles) = buf[i]; - state = match collapse_state(content, styles) { - SpaceState::Invisible => state, - SpaceState::Destructive => { - if state == SpaceState::Space { - buf.copy_within(prev_space + 1..cursor, prev_space); - cursor -= 1; - } - SpaceState::Destructive + let action; + (action, state) = collapse_transition(state, content, styles); + match action { + SpaceAction::Invisible => {} + SpaceAction::Skip => continue, + SpaceAction::Discard => { + buf.copy_within(prev_space + 1..cursor, prev_space); + cursor -= 1; } - SpaceState::Supportive => SpaceState::Supportive, - SpaceState::Space => { - if state != SpaceState::Supportive { - continue; - } + SpaceAction::Keep => { prev_space = cursor; - SpaceState::Space } - }; + } // Copy over normal elements (in place). if cursor < i { @@ -65,7 +94,7 @@ pub(crate) fn collapse_spaces(buf: &mut Vec, start: usize) { cursor += 1; } - if state == SpaceState::Space { + if matches!(state, SpaceState::Space { .. }) { buf.copy_within(prev_space + 1..cursor, prev_space); cursor -= 1; } @@ -74,15 +103,19 @@ pub(crate) fn collapse_spaces(buf: &mut Vec, start: usize) { buf.truncate(cursor); } -/// Space collapsing state for general elements. -pub(crate) fn collapse_state(content: &Content, styles: StyleChain) -> SpaceState { +/// How to transition state for the space collapsing algorithm. +fn collapse_transition<'a>( + state: SpaceState<'a>, + content: &'a Content, + styles: StyleChain<'_>, +) -> (SpaceAction, SpaceState<'a>) { if content.is::() { - SpaceState::Invisible + (SpaceAction::Invisible, state) } else if let Some(elem) = content.to_packed::() { if elem.amount.is_fractional() || elem.weak.get(styles) { - SpaceState::Destructive + for_destructive(state) } else { - SpaceState::Invisible + (SpaceAction::Invisible, state) } } else if content.is::() // We want to collapse spaces that would otherwise be protected and show @@ -91,32 +124,84 @@ pub(crate) fn collapse_state(content: &Content, styles: StyleChain) -> SpaceStat typst_html::tag::is_whitespace_collapsing(elem.tag) }) { - SpaceState::Destructive - } else if content.is::() { - SpaceState::Space + for_destructive(state) + } else if let Some(elem) = content.to_packed::() { + for_space(state, elem.had_newline) + } else if let Some(elem) = content.to_packed::() { + for_supportive(state, &elem.text) } else { - SpaceState::Supportive + for_supportive(state, "") } } -/// Space collapsing state for textual elements used during regex matching. -pub(crate) fn collapse_state_textual<'a>( +/// How to transition state for space collapsing during regex matching. +pub(crate) fn collapse_transition_textual<'a>( + state: SpaceState<'a>, content: &'a Content, styles: StyleChain<'_>, -) -> (SpaceState, &'a str) { - if content.is::() { - (SpaceState::Invisible, "") +) -> (SpaceAction, SpaceState<'a>, &'a str) { + let text; + // Roughly ordered from most to least common. + let (action, state) = if content.is::() { + text = ""; + (SpaceAction::Invisible, state) } else if content.is::() { - (SpaceState::Destructive, "\n") - } else if content.is::() { - (SpaceState::Space, " ") + text = "\n"; + for_destructive(state) + } else if let Some(elem) = content.to_packed::() { + text = " "; + for_space(state, elem.had_newline) } else if let Some(elem) = content.to_packed::() { - (SpaceState::Supportive, &elem.text) + text = &elem.text; + for_supportive(state, text) } else if let Some(elem) = content.to_packed::() { - let text = if elem.double.get(styles) { "\"" } else { "'" }; - (SpaceState::Supportive, text) + text = if elem.double.get(styles) { "\"" } else { "'" }; + for_supportive(state, text) } else { let name = content.elem().name(); panic!("tried to find regex match in a non-textual element: {name}"); + }; + (action, state, text) +} + +/// The state transition for a destructive element. +fn for_destructive(state: SpaceState) -> (SpaceAction, SpaceState) { + if matches!(state, SpaceState::Space { .. }) { + (SpaceAction::Discard, SpaceState::Destructive) + } else { + (SpaceAction::Keep, SpaceState::Destructive) + } +} + +/// The state transition for a space element. +/// +/// If any space in a group of spaces had a newline, we treat all spaces in that +/// group as having a newline. +fn for_space(state: SpaceState, had_newline: bool) -> (SpaceAction, SpaceState) { + match state { + SpaceState::Destructive => (SpaceAction::Skip, SpaceState::Destructive), + SpaceState::Supportive { text: prev_text } => { + (SpaceAction::Keep, SpaceState::Space { prev_text, had_newline }) + } + SpaceState::Space { prev_text, had_newline: prev_nl } => ( + SpaceAction::Skip, + SpaceState::Space { prev_text, had_newline: prev_nl || had_newline }, + ), + } +} + +/// The state transition for a supportive element. `text` should be empty if +/// this is not a textual element. +fn for_supportive<'a>( + state: SpaceState<'_>, + text: &'a str, +) -> (SpaceAction, SpaceState<'a>) { + if let SpaceState::Space { prev_text, had_newline } = state + && had_newline + && discard_space_between(prev_text, text) + { + (SpaceAction::Discard, SpaceState::Supportive { text }) + } else { + (SpaceAction::Keep, SpaceState::Supportive { text }) } } diff --git a/crates/typst-render/Cargo.toml b/crates/typst-render/Cargo.toml index 347bec025111..894880be829c 100644 --- a/crates/typst-render/Cargo.toml +++ b/crates/typst-render/Cargo.toml @@ -17,6 +17,7 @@ typst-assets = { workspace = true } typst-layout = { workspace = true } typst-library = { workspace = true } typst-macros = { workspace = true } +typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } bytemuck = { workspace = true } diff --git a/crates/typst-render/src/format.rs b/crates/typst-render/src/format.rs new file mode 100644 index 000000000000..f8d04204435a --- /dev/null +++ b/crates/typst-render/src/format.rs @@ -0,0 +1,118 @@ +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::Engine; +use typst_library::format::{Complete, Fields, Format, FormatElement, Partial, Populate}; +use typst_library::foundations::{Args, Construct, Content, StyleChain}; +use typst_macros::elem; +use typst_syntax::Spanned; +use typst_utils::Scalar; + +/// The format element for registering the PNG format. +pub const FORMAT: Format = Format::new::(); + +/// Typst's PNG export format. +/// +/// Instead of creating a PDF, Typst can also directly render pages to PNG +/// raster graphics. PNGs are losslessly compressed images that can contain one +/// page at a time. When exporting a multi-page document, Typst will emit +/// multiple PNGs. PNGs are a good choice when you want to use Typst's output in +/// an image editing software or when you can use none of Typst's other export +/// formats. +/// +/// In contrast to Typst's other export formats, PNGs are bound to a specific +/// resolution. When exporting to PNG, you can configure the resolution as +/// pixels per inch (PPI). If the medium you view the PNG on has a finer +/// resolution than the PNG you exported, you will notice a loss of quality. +/// Typst calculates the resolution of your PNGs based on each page's physical +/// dimensions and the PPI. If you need guidance for choosing a PPI value, +/// consider the following: +/// +/// - A value of 300 or 600 is typical for desktop printing. +/// - Professional prints of detailed graphics can go up to 1200 PPI. +/// - If your document is only viewed at a distance, e.g. a poster, you may +/// choose a smaller value than 300. +/// - If your document is viewed on screens, a typical PPI value for a +/// smartphone is 400-500. +/// +/// Because PNGs only contain a pixel raster, the text within cannot be +/// extracted automatically (without OCR), for example by copy/paste or a screen +/// reader. If you need the text to be accessible, export a PDF or HTML file +/// instead. +/// +/// PNGs can have transparent backgrounds. By default, Typst will output a PNG +/// with an opaque white background. You can make the background transparent +/// using `[#set page(fill: none)]`. Learn more on the @page.fill[`page` +/// function's reference page]. +/// +/// = Exporting as PNG +/// == Command Line +/// Pass `--format png` to the `compile` or `watch` subcommand or provide an +/// output file name that ends with `.png`. +/// +/// If your document has more than one page, Typst will create multiple image +/// files. The output file name must then be a template string containing at +/// least one of +/// - `[{p}]`, which will be replaced by the page number +/// - `[{0p}]`, which will be replaced by the zero-padded page number (so that +/// all numbers have the same length) +/// - `[{t}]`, which will be replaced by the total number of pages +/// +/// When exporting to PNG, you have the following configuration options: +/// +/// - Which resolution to render at by specifying `--ppi` followed by a number +/// of pixels per inch. The default is `144`. +/// +/// - Which pages to export by specifying `--pages` followed by a +/// comma-separated list of numbers or dash-separated number ranges. Ranges +/// can be half-open. Example: `2,3,7-9,11-`. +/// +/// == Web App +/// Click "File" > "Export as" > "PNG" or click the downwards-facing arrow next +/// to the quick download button and select "Export as PNG". When exporting to +/// PNG, you have the following configuration options: +/// +/// - The resolution at which the pages should be rendered, as a number of +/// pixels per inch. The default is `144`. +/// +/// - Which pages to export. Valid options are "All pages", "Current page", and +/// "Custom ranges". Custom ranges are a comma-separated list of numbers or +/// dash-separated number ranges. Ranges can be half-open. Example: +/// `2,3,7-9,11-`. +#[elem(name = "png", title = "PNG", since = "unreleased", Construct)] +pub struct PngFormat { + /// The PPI (pixels per inch) to use for PNG export. + #[default(Scalar::new(144.0))] + pub ppi: Scalar, +} + +impl FormatElement for PngFormat { + type Options = PngFormatOptions; +} + +impl Construct for PngFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +/// Document settings for PNG export. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct PngFormatOptions { + /// The number of pixels per point to render at when exporting a PNG. + pub pixel_per_pt: F::Value, +} + +impl Populate for PngFormatOptions { + fn populate(&mut self, styles: Spanned) { + // VOLATILE: This must be updated when adding more fields. + self.pixel_per_pt.populate(styles); + } +} + +impl PngFormatOptions { + /// Resolves the [`Partial`] options to [`Complete`] ones, given defaults. + pub fn resolve(&self, default: &PngFormatOptions) -> PngFormatOptions { + PngFormatOptions { + pixel_per_pt: Partial::resolve(self.pixel_per_pt, default.pixel_per_pt), + } + } +} diff --git a/crates/typst-render/src/lib.rs b/crates/typst-render/src/lib.rs index d38dff242e80..3c4c4366dcbf 100644 --- a/crates/typst-render/src/lib.rs +++ b/crates/typst-render/src/lib.rs @@ -1,28 +1,32 @@ //! Rendering of Typst documents into raster images. +mod format; mod image; mod paint; mod shape; mod text; +pub use self::format::{FORMAT, PngFormat, PngFormatOptions}; + use tiny_skia as sk; use typst_layout::{Page, PagedDocument}; +use typst_library::format::{Complete, Fields, Partial}; use typst_library::layout::{ Abs, Axes, Frame, FrameItem, FrameKind, GroupItem, Point, Sides, Size, Transform, }; +use typst_library::model::Document; use typst_library::visualize::{Color, Geometry, Paint}; -use typst_utils::Scalar; /// Export a page into a raster image. /// /// This renders the page at the given number of pixels per point and returns /// the resulting `tiny-skia` pixel buffer. #[typst_macros::time(name = "render")] -pub fn render(page: &Page, opts: &RenderOptions) -> sk::Pixmap { +pub fn render(page: &Page, opts: &RenderOptions) -> sk::Pixmap { let bleed = if opts.render_bleed { page.bleed } else { Sides::default() }; let size = page.frame.size() + bleed.sum_by_axis(); - let pixel_per_pt = opts.pixel_per_pt.get() as f32; + let pixel_per_pt = opts.format.pixel_per_pt.v.get() as f32; let pxw = (pixel_per_pt * size.x.to_f32()).round().max(1.0) as u32; let pxh = (pixel_per_pt * size.y.to_f32()).round().max(1.0) as u32; @@ -54,10 +58,11 @@ pub fn render_merged( gap: Abs, fill: Option, ) -> sk::Pixmap { + let opts = opts.resolve(document.options().get::()); let pixmaps: Vec<_> = - document.pages().iter().map(|page| render(page, opts)).collect(); + document.pages().iter().map(|page| render(page, &opts)).collect(); - let pixel_per_pt = opts.pixel_per_pt.get() as f32; + let pixel_per_pt = opts.format.pixel_per_pt.v.get() as f32; let gap = (pixel_per_pt * gap.to_f32()).round() as u32; let pxw = pixmaps.iter().map(sk::Pixmap::width).max().unwrap_or_default(); let pxh = pixmaps.iter().map(|pixmap| pixmap.height()).sum::() @@ -86,28 +91,24 @@ pub fn render_merged( } /// Settings for raster image export. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct RenderOptions { - /// Controls the scale of the rendered output in pixels per typographic - /// point. By default, a value of `1.0` is used, meaning one pixel is - /// generated per point. Increasing this value produces higher-resolution - /// images, while lower values reduce the output size and rendering cost. - /// This can be useful when adjusting the final image quality for display or - /// printing purposes. - pub pixel_per_pt: Scalar, +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct RenderOptions { /// By default, rendered pages are bounded to the page size. In some /// circumstances, such as when preparing documents for print, it may be /// desirable to include content beyond these bounds to account for bleed /// margins. This field allows expanding the rendered area to include such /// bleed. pub render_bleed: bool, + /// Format options that override the defaults set by the document. + pub format: PngFormatOptions, } -impl Default for RenderOptions { - fn default() -> Self { - Self { - pixel_per_pt: Scalar::new(2.0), - render_bleed: false, +impl RenderOptions { + /// Resolves the final options given those set by the document. + pub fn resolve(&self, doc: &PngFormatOptions) -> RenderOptions { + RenderOptions { + render_bleed: self.render_bleed, + format: self.format.resolve(doc), } } } diff --git a/crates/typst-render/src/paint.rs b/crates/typst-render/src/paint.rs index 919913dc2bee..8cc31d0fce95 100644 --- a/crates/typst-render/src/paint.rs +++ b/crates/typst-render/src/paint.rs @@ -6,7 +6,7 @@ use typst_library::visualize::{ Geometry, Gradient, Paint, ProcessColor, RelativeTo, Shape, Tiling, }; -use crate::{AbsExt, State}; +use crate::{AbsExt, State, to_sk_transform}; /// Trait for sampling of a paint, used as a generic /// abstraction over solid colors and gradients. @@ -102,11 +102,13 @@ impl<'a> TilingSampler<'a> { RelativeTo::Self_ => sk::Transform::identity(), RelativeTo::Parent => state.container_transform.invert().unwrap(), }; + let pattern_transform = to_sk_transform(&tilings.transform()); Self { pixmap, size: (tilings.size() + tilings.spacing()) * state.pixel_per_pt as f64, - transform_to_parent: fill_transform, + transform_to_parent: fill_transform + .post_concat(pattern_transform.invert().unwrap_or_default()), pixel_per_pt: state.pixel_per_pt, } } @@ -270,10 +272,7 @@ pub fn to_sk_paint<'a>( sk::FilterQuality::Nearest, 1.0, fill_transform - .pre_translate( - tilings.offset().x.to_f32(), - tilings.offset().y.to_f32(), - ) + .pre_concat(to_sk_transform(&tilings.transform())) .pre_scale(1.0 / state.pixel_per_pt, 1.0 / state.pixel_per_pt) .pre_translate(base_offset.x.to_f32(), base_offset.y.to_f32()), ); diff --git a/crates/typst-svg/Cargo.toml b/crates/typst-svg/Cargo.toml index 785c981aa146..cacf4eee22bc 100644 --- a/crates/typst-svg/Cargo.toml +++ b/crates/typst-svg/Cargo.toml @@ -17,6 +17,7 @@ typst-assets = { workspace = true } typst-layout = { workspace = true } typst-library = { workspace = true } typst-macros = { workspace = true } +typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } base64 = { workspace = true } diff --git a/crates/typst-svg/src/format.rs b/crates/typst-svg/src/format.rs new file mode 100644 index 000000000000..b099a3e0cc1c --- /dev/null +++ b/crates/typst-svg/src/format.rs @@ -0,0 +1,105 @@ +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::Engine; +use typst_library::format::{Complete, Fields, Format, FormatElement, Partial, Populate}; +use typst_library::foundations::{Args, Construct, Content, StyleChain}; +use typst_macros::elem; +use typst_syntax::Spanned; + +/// The format element for registering the SVG format. +pub const FORMAT: Format = Format::new::(); + +/// Typst's SVG export format. +/// +/// Instead of creating a PDF, Typst can also directly render pages to scalable +/// vector graphics (SVGs), which are the preferred format for embedding vector +/// graphics in web pages. Like PDF files, SVGs display your document exactly +/// how you have laid it out in Typst. Likewise, they share the benefit of not +/// being bound to a specific resolution. Hence, you can print or view SVG files +/// on any device without incurring a loss of quality. (Note that font printing +/// quality may be better with a PDF.) In contrast to a PDF, an SVG cannot +/// contain multiple pages. When exporting a multi-page document, Typst will +/// emit multiple SVGs. +/// +/// SVGs can represent text in two ways: By embedding the text itself and +/// rendering it with the fonts available on the viewer's computer or by +/// embedding the shapes of each glyph in the font used to create the document. +/// To ensure that the SVG file looks the same across all devices it is viewed +/// on, Typst chooses the latter method. This means that the text in the SVG +/// cannot be extracted automatically, for example by copy/paste or a screen +/// reader. If you need the text to be accessible, export a PDF or HTML file +/// instead. +/// +/// SVGs can have transparent backgrounds. By default, Typst will output an SVG +/// with an opaque white background. You can make the background transparent +/// using `[#set page(fill: none)]`. Learn more on the @page.fill[`page` +/// function's reference page]. +/// +/// = Exporting as SVG +/// == Command Line +/// Pass `--format svg` to the `compile` or `watch` subcommand or provide an +/// output file name that ends with `.svg`. +/// +/// If your document has more than one page, Typst will create multiple image +/// files. The output file name must then be a template string containing at +/// least one of +/// - `[{p}]`, which will be replaced by the page number +/// - `[{0p}]`, which will be replaced by the zero-padded page number (so that +/// all numbers have the same length) +/// - `[{t}]`, which will be replaced by the total number of pages +/// +/// When exporting to SVG, you have the following configuration options: +/// +/// - Which pages to export by specifying `--pages` followed by a +/// comma-separated list of numbers or dash-separated number ranges. Ranges +/// can be half-open. Example: `2,3,7-9,11-`. +/// +/// == Web App +/// Click "File" > "Export as" > "SVG" or click the downwards-facing arrow next +/// to the quick download button and select "Export as SVG". When exporting to +/// SVG, you have the following configuration options: +/// +/// - Which pages to export. Valid options are "All pages", "Current page", and +/// "Custom ranges". Custom ranges are a comma-separated list of numbers or +/// dash-separated number ranges. Ranges can be half-open. Example: +/// `2,3,7-9,11-`. +#[elem(name = "svg", title = "SVG", since = "unreleased", Construct)] +pub struct SvgFormat { + /// Whether to pretty-print the produced SVG file. + /// + /// This formats the output in a more human-readable, but less + /// space-efficient way. + #[default(false)] + pub pretty: bool, +} + +impl FormatElement for SvgFormat { + type Options = SvgFormatOptions; +} + +impl Construct for SvgFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +/// Document settings for SVG export. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct SvgFormatOptions { + pub pretty: F::Value, +} + +impl SvgFormatOptions { + /// Resolves the [`Partial`] options to [`Complete`] ones, given defaults. + pub fn resolve(&self, default: &SvgFormatOptions) -> SvgFormatOptions { + SvgFormatOptions { + pretty: Partial::resolve(self.pretty, default.pretty), + } + } +} + +impl Populate for SvgFormatOptions { + fn populate(&mut self, styles: Spanned) { + // VOLATILE: This must be updated when adding more fields. + self.pretty.populate(styles); + } +} diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs index 498f4eeb5aff..0b823cbbddcb 100644 --- a/crates/typst-svg/src/lib.rs +++ b/crates/typst-svg/src/lib.rs @@ -1,5 +1,6 @@ //! Rendering of Typst documents into SVG images. +mod format; mod image; mod paint; mod path; @@ -7,19 +8,21 @@ mod shape; mod text; mod write; -use comemo::Tracked; -pub use image::{WebImage, convert_image_scaling}; -use indexmap::IndexMap; -use rustc_hash::FxBuildHasher; -use typst_library::model::{Destination, LateLinkResolver}; +pub use self::format::{FORMAT, SvgFormat, SvgFormatOptions}; +pub use self::image::{WebImage, convert_image_scaling}; use std::hash::Hash; +use comemo::Tracked; use ecow::EcoString; +use indexmap::IndexMap; +use rustc_hash::FxBuildHasher; use typst_layout::{Page, PagedDocument}; +use typst_library::format::{Complete, Fields, Partial}; use typst_library::layout::{ Abs, Frame, FrameItem, FrameKind, GroupItem, Point, Ratio, Sides, Size, Transform, }; +use typst_library::model::{Destination, Document, LateLinkResolver}; use typst_library::visualize::{Geometry, Gradient, Tiling}; use xmlwriter::XmlWriter; @@ -29,11 +32,11 @@ use crate::write::{SvgDisplay, SvgElem, SvgTransform, SvgUrl, SvgWrite}; /// Export a frame into an SVG file. #[typst_macros::time(name = "svg")] -pub fn svg(page: &Page, opts: &SvgOptions) -> String { +pub fn svg(page: &Page, opts: &SvgOptions) -> String { let (size, ts) = page_bleed(page, opts); let mut renderer = SVGRenderer::new(); - let mut xml = XmlWriter::new(xml_options(opts.pretty)); + let mut xml = XmlWriter::new(xml_options(opts.format.pretty.v)); let mut svg = svg_header(&mut xml, size); let state = State::new(size); @@ -51,14 +54,14 @@ pub fn svg(page: &Page, opts: &SvgOptions) -> String { #[typst_macros::time(name = "svg in bundle")] pub fn svg_in_bundle( page: &Page, - opts: &SvgOptions, + opts: &SvgOptions, anchors: &[(Point, EcoString)], link_resolver: Tracked, ) -> String { let (size, ts) = page_bleed(page, opts); let mut renderer = SVGRenderer::with_options(Some(link_resolver)); - let mut xml = XmlWriter::new(xml_options(opts.pretty)); + let mut xml = XmlWriter::new(xml_options(opts.format.pretty.v)); let mut svg = svg_header(&mut xml, size); let state = State::new(size); @@ -123,21 +126,22 @@ pub fn svg_in_html( /// /// The gap will be added between the individual pages. pub fn svg_merged(document: &PagedDocument, opts: &SvgOptions, gap: Abs) -> String { + let opts = opts.resolve(document.options().get::()); let num_gaps = document.pages().len().saturating_sub(1) as f64; let mut size = Size::new(Abs::zero(), num_gaps * gap); for page in document.pages() { - let (page_size, _ts) = page_bleed(page, opts); + let (page_size, _ts) = page_bleed(page, &opts); size.x.set_max(page_size.x); size.y += page_size.y; } let mut renderer = SVGRenderer::new(); - let mut xml = XmlWriter::new(xml_options(opts.pretty)); + let mut xml = XmlWriter::new(xml_options(opts.format.pretty.v)); let mut svg = svg_header(&mut xml, size); let mut y = Abs::zero(); for page in document.pages() { - let (page_size, bleed_ts) = page_bleed(page, opts); + let (page_size, bleed_ts) = page_bleed(page, &opts); let state = State::new(page_size); renderer.render_page( &mut svg, @@ -152,7 +156,7 @@ pub fn svg_merged(document: &PagedDocument, opts: &SvgOptions, gap: Abs) -> Stri xml.end_document() } -fn page_bleed(page: &Page, opts: &SvgOptions) -> (Size, Transform) { +fn page_bleed(page: &Page, opts: &SvgOptions) -> (Size, Transform) { let bleed = if opts.render_bleed { page.bleed } else { Sides::default() }; let size = page.frame.size() + bleed.sum_by_axis(); let ts = Transform::translate(bleed.left, bleed.top); @@ -173,15 +177,25 @@ fn xml_options(pretty: bool) -> xmlwriter::Options { /// Settings for SVG export. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] -pub struct SvgOptions { +pub struct SvgOptions { /// By default, SVG documents are bounded to the page size. In some /// circumstances, such as when preparing documents for print, it may be /// desirable to include content beyond these bounds to account for bleed /// margins. This field allows expanding the document area to include such /// bleed. pub render_bleed: bool, - /// Whether to format the SVG in a human-readable way. - pub pretty: bool, + /// Format options that override the defaults set by the document. + pub format: SvgFormatOptions, +} + +impl SvgOptions { + /// Resolves the final options given those set by the document. + pub fn resolve(&self, doc: &SvgFormatOptions) -> SvgOptions { + SvgOptions { + render_bleed: self.render_bleed, + format: self.format.resolve(doc), + } + } } /// Renders one or multiple frames to an SVG file. diff --git a/crates/typst-svg/src/paint.rs b/crates/typst-svg/src/paint.rs index 5e04986fa3c2..65b93ca526a7 100644 --- a/crates/typst-svg/src/paint.rs +++ b/crates/typst-svg/src/paint.rs @@ -87,22 +87,29 @@ impl SVGRenderer<'_> { pub(super) fn push_tiling(&mut self, tiling: &Tiling, ts: Transform) -> DedupId { let tiling_size = tiling.size() + tiling.spacing(); let tiling_offset = tiling.offset(); + let tiling_angle = tiling.angle(); // Unfortunately due to a limitation of `xmlwriter`, we need to // render the frame twice: once to allocate all of the resources // that it needs and once to actually render it. let rendered = self.render_tiling_frame(&State::new(tiling_size), tiling.frame()); - // Use the rendered SVG and the tiling's size and offset as a key, since the `Tiling` - // itself includes `Location`s which aren't stable. - let tiling_id = self - .tilings - .insert_with((tiling_size, tiling_offset, rendered), || tiling.clone()); + // Use the rendered SVG and the tiling's properties as a key, since the + // `Tiling` itself includes `Location`s which aren't stable. + let tiling_id = self.tilings.insert_with( + (tiling_size, tiling_offset, tiling_angle, rendered.as_str()), + || tiling.clone(), + ); if ts.is_identity() { return tiling_id; } - let tiling_ref = TilingRef { id: tiling_id, transform: ts }; + // The tiling reference is written as a SVG `pattern` with a `href` + // attribute, which will overwrite the `patternTransform` of the + // referenced pattern. Thus any pre-existing `patternTransform` of the + // referenced pattern needs to be concatenated. + let transform = ts.pre_concat(tiling.transform()); + let tiling_ref = TilingRef { id: tiling_id, transform }; self.tiling_refs.insert_with(tiling_ref, || tiling_ref) } @@ -341,13 +348,13 @@ impl SVGRenderer<'_> { self.tilings.iter().map(|(i, p)| (i, p.clone())).collect::>() { let size = tiling.size() + tiling.spacing(); + defs.elem("pattern") .attr("id", id) .attr("width", size.x.to_pt()) .attr("height", size.y.to_pt()) - .attr("x", tiling.offset().x.to_pt()) - .attr("y", tiling.offset().y.to_pt()) .attr("patternUnits", "userSpaceOnUse") + .attr("patternTransform", SvgTransform(tiling.transform())) .attr_with("viewBox", |attr| { attr.push_nums([0.0, 0.0, size.x.to_pt(), size.y.to_pt()]); }) diff --git a/crates/typst-svg/src/write.rs b/crates/typst-svg/src/write.rs index eb54af1fcffd..7c5f32e56bbd 100644 --- a/crates/typst-svg/src/write.rs +++ b/crates/typst-svg/src/write.rs @@ -90,10 +90,12 @@ impl Drop for LazySvgElem<'_, '_> { } pub trait SvgWrite: Sized { - /// Write a string, escaping is handled by [`xmlwriter`]. + /// Write a string. When writing to an attribute value, XML metacharacters + /// are escaped. fn push_str(&mut self, value: &str); - /// Write a character, escaping is handled by [`xmlwriter`]. + /// Write a character. When writing to an attribute value, XML + /// metacharacters are escaped. fn push_char(&mut self, value: char) { self.push_str(value.encode_utf8(&mut [0; 4])); } @@ -151,13 +153,60 @@ impl<'a, T> SvgFormatter<'a, T> { impl SvgWrite for SvgFormatter<'_, Vec> { fn push_str(&mut self, value: &str) { - self.buf.extend_from_slice(value.as_bytes()); + escape_str(value, |str| self.buf.extend_from_slice(str.as_bytes())); } } impl SvgWrite for SvgFormatter<'_, EcoString> { fn push_str(&mut self, value: &str) { - self.buf.push_str(value); + escape_str(value, |str| self.buf.push_str(str)); + } +} + +/// Append `value` to a buffer, escaping the XML metacharacters that +/// `xmlwriter`'s raw attribute writer leaves untouched (it only escapes the +/// quotation mark). Otherwise a value such as a link URL containing `&` or `<` +/// yields malformed SVG. Runs of non-escaped characters are appended in one go. +fn escape_str(mut value: &str, mut append: impl FnMut(&str)) { + while let Some((i, c)) = value + .bytes() + .enumerate() + .find_map(|(i, b)| EscapedChar::from_byte(b).map(|c| (i, c))) + { + append(&value[..i]); + append(c.escape_sequence()); + + // Escaped characters are always one byte. + value = &value[i + 1..]; + } + if !value.is_empty() { + append(value); + } +} + +/// A character that should be escaped in XML attribute values. +#[derive(Copy, Clone)] +enum EscapedChar { + /// `&` + Amp, + /// `<` + Lt, +} + +impl EscapedChar { + const fn from_byte(byte: u8) -> Option { + match byte { + b'&' => Some(Self::Amp), + b'<' => Some(Self::Lt), + _ => None, + } + } + + const fn escape_sequence(self) -> &'static str { + match self { + Self::Amp => "&", + Self::Lt => "<", + } } } diff --git a/crates/typst-syntax/src/ast.rs b/crates/typst-syntax/src/ast.rs index 8ed7c3aa2c83..b1b73fe540c6 100644 --- a/crates/typst-syntax/src/ast.rs +++ b/crates/typst-syntax/src/ast.rs @@ -237,7 +237,11 @@ impl<'a> Markup<'a> { .filter(move |node| { // Ignore newline directly after statements without semicolons. let kind = node.kind(); - let keep = !was_stmt || node.kind() != SyntaxKind::Space; + let is_space = matches!( + node.kind(), + SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline + ); + let keep = !was_stmt || !is_space; was_stmt = kind.is_stmt(); keep }) @@ -255,7 +259,8 @@ pub enum Expr<'a> { Space(Space<'a>), /// A forced line break: `\`. Linebreak(Linebreak<'a>), - /// A paragraph break, indicated by one or multiple blank lines. + /// A paragraph break, indicated by contiguous whitespace containing at + /// least two newlines. Only produced in Markup. Parbreak(Parbreak<'a>), /// An escape sequence: `\#`, `\u{1F5FA}`. Escape(Escape<'a>), @@ -377,7 +382,9 @@ pub enum Expr<'a> { impl<'a> Expr<'a> { fn cast_with_space(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Space => Some(Self::Space(Space(node))), + SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline => { + Some(Self::Space(Space(node))) + } _ => Self::from_untyped(node), } } @@ -386,7 +393,7 @@ impl<'a> Expr<'a> { impl<'a> AstNode<'a> for Expr<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Space => Option::None, // Skipped unless using `cast_with_space`. + // Spaces are skipped unless using `cast_with_space`. SyntaxKind::Linebreak => Some(Self::Linebreak(Linebreak(node))), SyntaxKind::Parbreak => Some(Self::Parbreak(Parbreak(node))), SyntaxKind::Text => Some(Self::Text(Text(node))), @@ -590,10 +597,35 @@ impl<'a> Text<'a> { } } -node! { - /// Whitespace in markup or math. Has at most one newline in markup, as more - /// indicate a paragraph break. - struct Space +/// Whitespace in markup or math. Has at most one newline in markup, as more +/// indicate a paragraph break. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Space<'a>(&'a SyntaxNode); + +impl Space<'_> { + /// Whether the Space contains a newline. + pub fn had_newline(self) -> bool { + self.0.kind() == SyntaxKind::SpaceWithNewline + } +} + +impl<'a> AstNode<'a> for Space<'a> { + fn from_untyped(node: &'a SyntaxNode) -> Option { + match node.kind() { + SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline => Some(Self(node)), + _ => Option::None, + } + } + + fn to_untyped(self) -> &'a SyntaxNode { + self.0 + } + + fn placeholder() -> Self { + static PLACEHOLDER: SyntaxNode = + SyntaxNode::placeholder(SyntaxKind::SpaceNoNewline); + Self(&PLACEHOLDER) + } } node! { @@ -602,7 +634,8 @@ node! { } node! { - /// A paragraph break, indicated by one or multiple blank lines. + /// A paragraph break, indicated by contiguous whitespace containing at + /// least two newlines. Only produced in Markup. struct Parbreak } @@ -873,7 +906,10 @@ impl<'a> Equation<'a> { /// Whether the equation should be displayed as a separate block. pub fn block(self) -> bool { let is_space = |node: Option<&SyntaxNode>| { - node.map(SyntaxNode::kind) == Some(SyntaxKind::Space) + matches!( + node.map(SyntaxNode::kind), + Some(SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline) + ) }; is_space(self.0.children().nth(1)) && is_space(self.0.children().nth_back(1)) } @@ -1152,7 +1188,9 @@ impl<'a> MathArgs<'a> { let semicolon_ends_code = prev_hash; prev_hash = false; let item = match node.kind() { - SyntaxKind::Space => MathArgItem::Space(Space(node)), + SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline => { + MathArgItem::Space(Space(node)) + } SyntaxKind::Comma => MathArgItem::Comma(',', node), SyntaxKind::LeftParen => MathArgItem::LeftParen('(', node), SyntaxKind::RightParen => MathArgItem::RightParen(')', node), diff --git a/crates/typst-syntax/src/highlight.rs b/crates/typst-syntax/src/highlight.rs index 88077582684f..37d67a39d7df 100644 --- a/crates/typst-syntax/src/highlight.rs +++ b/crates/typst-syntax/src/highlight.rs @@ -149,7 +149,8 @@ pub fn highlight(node: &LinkedNode) -> Option { } SyntaxKind::Markup => None, SyntaxKind::Text => None, - SyntaxKind::Space => None, + SyntaxKind::SpaceNoNewline => None, + SyntaxKind::SpaceWithNewline => None, SyntaxKind::Linebreak => Some(Tag::Escape), SyntaxKind::Parbreak => None, SyntaxKind::Escape => Some(Tag::Escape), diff --git a/crates/typst-syntax/src/kind.rs b/crates/typst-syntax/src/kind.rs index 93fe0c700705..73b41a891d4a 100644 --- a/crates/typst-syntax/src/kind.rs +++ b/crates/typst-syntax/src/kind.rs @@ -22,12 +22,15 @@ pub enum SyntaxKind { Markup, /// Plain text without markup. Text, - /// Whitespace. Contains at most one newline in markup, as more indicate a - /// paragraph break. - Space, + /// Whitespace with no newlines. + SpaceNoNewline, + /// Whitespace with _exactly one_ newline in Markup (more would be a + /// `ParBreak`) and _at least one_ newline in Code/Math. + SpaceWithNewline, /// A forced line break: `\`. Linebreak, - /// A paragraph break, indicated by one or multiple blank lines. + /// A paragraph break, indicated by contiguous whitespace containing at + /// least two newlines. Only produced in Markup. Parbreak, /// An escape sequence: `\#`, `\u{1F5FA}`. Escape, @@ -372,7 +375,8 @@ impl SyntaxKind { Self::Shebang | Self::LineComment | Self::BlockComment - | Self::Space + | Self::SpaceNoNewline + | Self::SpaceWithNewline | Self::Parbreak ) } @@ -392,7 +396,8 @@ impl SyntaxKind { Self::BlockComment => "block comment", Self::Markup => "markup", Self::Text => "text", - Self::Space => "space", + Self::SpaceNoNewline => "space", + Self::SpaceWithNewline => "space", Self::Linebreak => "line break", Self::Parbreak => "paragraph break", Self::Escape => "escape sequence", @@ -586,9 +591,10 @@ impl SyntaxKind { Self::BlockComment => None, // none Self::Markup => Known(Markup), - Self::Text => Text, // none: Raw | markup: expr - Self::Space => Space, // code/math: part | markup: expr/part - Self::Linebreak => Parent, // math/markup: expr + Self::Text => Text, // none: Raw | markup: expr + Self::SpaceNoNewline => Space, // code: part | markup/math: expr/part + Self::SpaceWithNewline => Space, // code: part | markup/math: expr/part + Self::Linebreak => Parent, // math/markup: expr Self::Parbreak => Known(Markup), Self::Escape => Parent, // math/markup: expr Self::Shorthand => Known(Markup), @@ -849,5 +855,11 @@ mod test { test_mode("$ #$x$; $", [2, 5], Some(Code)); test_mode("#[$x$]", [2, 3], Some(Math)); test_mode("#[$x$]", [1, 4], Some(Markup)); + + // Single newlines at the edges of modes + // (since the other tests mostly use normal spaces instead of newlines) + test_mode("#[\n$\n#{\nx\n}\n$\n]", [2, 13], Some(Markup)); + test_mode("#[\n$\n#{\nx\n}\n$\n]", [4, 11], Some(Math)); + test_mode("#[\n$\n#{\nx\n}\n$\n]", [7, 9], Some(Code)); } } diff --git a/crates/typst-syntax/src/lexer.rs b/crates/typst-syntax/src/lexer.rs index 43f69831ef55..43b269c64c12 100644 --- a/crates/typst-syntax/src/lexer.rs +++ b/crates/typst-syntax/src/lexer.rs @@ -19,23 +19,15 @@ pub(super) struct Lexer<'s> { /// The mode the lexer is in. This determines which kinds of tokens it /// produces. mode: SyntaxMode, - /// Whether the last token contained a newline. - newline: bool, /// An error plus hints for the current token being produced. This is always /// `None` between calls to [`Lexer::next`]. error: Option<(EcoString, EcoVec)>, } impl<'s> Lexer<'s> { - /// Create a new lexer with the given mode and a prefix to offset column - /// calculations. + /// Create a new lexer with the given mode. pub fn new(text: &'s str, mode: SyntaxMode) -> Self { - Self { - s: Scanner::new(text), - mode, - newline: false, - error: None, - } + Self { s: Scanner::new(text), mode, error: None } } /// Get the current lexing mode. @@ -59,11 +51,6 @@ impl<'s> Lexer<'s> { self.s.jump(index); } - /// Whether the last token contained a newline. - pub fn newline(&self) -> bool { - self.newline - } - /// The number of characters until the most recent newline from an index. pub fn column(&self, index: usize) -> usize { let mut s = self.s; // Make a new temporary scanner (cheap). @@ -96,7 +83,6 @@ impl Lexer<'_> { debug_assert!(self.error.is_none()); let start = self.s.cursor(); - self.newline = false; let kind = match self.s.eat() { Some(c) if is_space(c, self.mode) => self.whitespace(start, c), Some('#') if start == 0 && self.s.eat_if('!') => self.shebang(), @@ -140,11 +126,12 @@ impl Lexer<'_> { _ => count_newlines(self.s.from(start)), }; - self.newline = newlines > 0; if self.mode == SyntaxMode::Markup && newlines >= 2 { SyntaxKind::Parbreak + } else if newlines > 0 { + SyntaxKind::SpaceWithNewline } else { - SyntaxKind::Space + SyntaxKind::SpaceNoNewline } } @@ -1130,7 +1117,8 @@ impl ScannerExt for Scanner<'_> { } } -/// Whether a character will become a [`SyntaxKind::Space`] token. +/// Whether a character will become a space token, either +/// [`SyntaxKind::SpaceNoNewline`] or [`SyntaxKind::SpaceWithNewline`]. #[inline] fn is_space(character: char, mode: SyntaxMode) -> bool { match mode { diff --git a/crates/typst-syntax/src/node.rs b/crates/typst-syntax/src/node.rs index 08a3de889b45..79526cb0fce4 100644 --- a/crates/typst-syntax/src/node.rs +++ b/crates/typst-syntax/src/node.rs @@ -1493,12 +1493,12 @@ mod tests { Markup: 14 [ Heading: 6 [ HeadingMarker: \"=\", - Space: \" \", + SpaceNoNewline: \" \", Markup: 4 [ Text: \"Head\", ], ], - Space: \" \", + SpaceNoNewline: \" \", Label: \"