diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c81537b..7180dcad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: sudo dpkg --add-architecture i386 sudo apt update sudo apt install -y gcc-multilib libssl-dev:i386 pkg-config:i386 - - uses: dtolnay/rust-toolchain@1.87.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: targets: ${{ matrix.bits == 32 && 'i686-unknown-linux-gnu' || '' }} - uses: Swatinem/rust-cache@v2 @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.87.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 @@ -81,13 +81,14 @@ jobs: - run: cargo clippy --workspace --all-targets --no-default-features - run: cargo fmt --check --all - run: cargo doc --workspace --no-deps + - run: git diff --exit-code min-version: name: Check minimum Rust version runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.83.0 + - uses: dtolnay/rust-toolchain@1.88.0 - uses: Swatinem/rust-cache@v2 - run: cargo check --workspace @@ -98,7 +99,19 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2024-10-29 + toolchain: nightly-2025-05-10 - uses: Swatinem/rust-cache@v2 - run: cargo install --locked cargo-fuzz@0.12.0 - run: cd tests/fuzz && cargo fuzz build --dev + + miri: + name: Check unsafe code + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + components: miri + toolchain: nightly-2025-05-10 + - uses: Swatinem/rust-cache@v2 + - run: cargo miri test -p typst-library test_miri diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca317abd0..566fc5808 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.87.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: target: ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index 218fa2e4d..3e5fb87ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,9 +181,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.8.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] @@ -214,9 +214,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.21.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" dependencies = [ "bytemuck_derive", ] @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "codex" version = "0.1.1" -source = "git+https://github.com/typst/codex?rev=56eb217#56eb2172fc0670f4c1c8b79a63d11f9354e5babe" +source = "git+https://github.com/typst/codex?rev=9ac86f9#9ac86f96af5b89fce555e6bba8b6d1ac7b44ef00" [[package]] name = "color-print" @@ -748,9 +748,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", "libz-rs-sys", @@ -786,9 +786,9 @@ checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "font-types" -version = "0.8.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" dependencies = [ "bytemuck", ] @@ -964,6 +964,69 @@ dependencies = [ "url", ] +[[package]] +name = "hayro" +version = "0.1.0" +source = "git+https://github.com/LaurenzV/hayro?rev=e701f95#e701f9569157a2fe4ade68930dc9e9283782dcca" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "hayro-font" +version = "0.1.0" +source = "git+https://github.com/LaurenzV/hayro?rev=e701f95#e701f9569157a2fe4ade68930dc9e9283782dcca" +dependencies = [ + "log", + "phf", +] + +[[package]] +name = "hayro-interpret" +version = "0.1.0" +source = "git+https://github.com/LaurenzV/hayro?rev=e701f95#e701f9569157a2fe4ade68930dc9e9283782dcca" +dependencies = [ + "bitflags 2.9.1", + "hayro-font", + "hayro-syntax", + "kurbo", + "log", + "phf", + "qcms", + "skrifa", + "smallvec", + "yoke 0.8.0", +] + +[[package]] +name = "hayro-syntax" +version = "0.0.1" +source = "git+https://github.com/LaurenzV/hayro?rev=e701f95#e701f9569157a2fe4ade68930dc9e9283782dcca" +dependencies = [ + "flate2", + "kurbo", + "log", + "rustc-hash", + "smallvec", + "zune-jpeg", +] + +[[package]] +name = "hayro-write" +version = "0.1.0" +source = "git+https://github.com/LaurenzV/hayro?rev=e701f95#e701f9569157a2fe4ade68930dc9e9283782dcca" +dependencies = [ + "flate2", + "hayro-syntax", + "log", + "pdf-writer", +] + [[package]] name = "heck" version = "0.5.0" @@ -1198,17 +1261,11 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "if_chain" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" - [[package]] name = "image" -version = "0.25.5" +version = "0.25.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" dependencies = [ "bytemuck", "byteorder-lite", @@ -1271,7 +1328,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "inotify-sys", "libc", ] @@ -1367,8 +1424,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ee6128ebf52d7ce684613b6431ead2959f2be9ff8cf776eeaaad0427c953e9" +source = "git+https://github.com/LaurenzV/krilla?rev=37b9a00#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd" dependencies = [ "base64", "bumpalo", @@ -1377,6 +1433,7 @@ dependencies = [ "float-cmp 0.10.0", "fxhash", "gif", + "hayro-write", "image-webp", "imagesize", "once_cell", @@ -1386,6 +1443,7 @@ dependencies = [ "rustybuzz", "siphasher", "skrifa", + "smallvec", "subsetter", "tiny-skia-path", "xmp-writer", @@ -1396,8 +1454,7 @@ dependencies = [ [[package]] name = "krilla-svg" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3462989578155cf620ef8035f8921533cc95c28e2a0c75de172f7219e6aba84e" +source = "git+https://github.com/LaurenzV/krilla?rev=37b9a00#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd" dependencies = [ "flate2", "fontdb", @@ -1410,9 +1467,9 @@ dependencies = [ [[package]] name = "kurbo" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89234b2cc610a7dd927ebde6b41dd1a5d4214cffaef4cf1fb2195d592f92518f" +checksum = "1077d333efea6170d9ccb96d3c3026f300ca0773da4938cc4c811daa6df68b0c" dependencies = [ "arrayvec", "smallvec", @@ -1464,16 +1521,16 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "libc", "redox_syscall", ] [[package]] name = "libz-rs-sys" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "902bc563b5d65ad9bba616b490842ef0651066a1a1dc3ce1087113ffcb873c8d" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" dependencies = [ "zlib-rs", ] @@ -1630,7 +1687,7 @@ version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fee8403b3d66ac7b26aee6e40a897d85dc5ce26f44da36b8b73e987cc52e943" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "filetime", "fsevent-sys", "inotify", @@ -1712,7 +1769,7 @@ version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -1849,7 +1906,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ea27c5015ab81753fc61e49f8cde74999346605ee148bb20008ef3d3150e0dc" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "itoa", "memchr", "ryu", @@ -2007,7 +2064,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "getopts", "memchr", "unicase", @@ -2106,9 +2163,9 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.28.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "600e807b48ac55bad68a8cb75cc3c7739f139b9248f7e003e01e080f589b5288" +checksum = "192735ef611aac958468e670cb98432c925426f3cb71521fda202130f7388d91" dependencies = [ "bytemuck", "font-types", @@ -2120,7 +2177,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", ] [[package]] @@ -2223,7 +2280,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys", @@ -2242,7 +2299,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "bytemuck", "core_maths", "log", @@ -2290,7 +2347,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "core-foundation", "core-foundation-sys", "libc", @@ -2434,9 +2491,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.30.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa1e5622e4f7b98877e8a19890efddcac1230cec6198bd9de91ec0e00010dc8" +checksum = "e6d632b5a73f566303dbeabd344dc3e716fd4ddc9a70d6fc8ea8e6f06617da97" dependencies = [ "bytemuck", "read-fonts", @@ -2453,9 +2510,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "spin" @@ -2863,7 +2920,7 @@ dependencies = [ [[package]] name = "typst-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-assets?rev=c74e539#c74e539b090070a0c66fd007c550f5b6d3b724bd" +source = "git+https://github.com/typst/typst-assets?rev=fbf00f9#fbf00f9539fdb0825bef4d39fb57d5986c51b756" [[package]] name = "typst-cli" @@ -2913,7 +2970,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-dev-assets?rev=fddbf8b#fddbf8b99506bc370ac0edcd4959add603a7fc92" +source = "git+https://github.com/typst/typst-dev-assets?rev=c6c2acf#c6c2acf6cdc31f99a23a478d3d614f8bf806a4f5" [[package]] name = "typst-docs" @@ -2945,7 +3002,6 @@ version = "0.13.1" dependencies = [ "comemo", "ecow", - "if_chain", "indexmap 2.7.1", "stacker", "toml", @@ -2973,8 +3029,12 @@ dependencies = [ name = "typst-html" version = "0.13.1" dependencies = [ + "bumpalo", "comemo", "ecow", + "palette", + "time", + "typst-assets", "typst-library", "typst-macros", "typst-svg", @@ -2989,7 +3049,6 @@ version = "0.13.1" dependencies = [ "comemo", "ecow", - "if_chain", "once_cell", "pathdiff", "serde", @@ -3030,6 +3089,7 @@ version = "0.13.1" dependencies = [ "az", "bumpalo", + "codex", "comemo", "ecow", "hypher", @@ -3060,7 +3120,7 @@ name = "typst-library" version = "0.13.1" dependencies = [ "az", - "bitflags 2.8.0", + "bitflags 2.9.1", "bumpalo", "chinese-number", "ciborium", @@ -3072,6 +3132,7 @@ dependencies = [ "fontdb", "glidesort", "hayagriva", + "hayro-syntax", "icu_properties", "icu_provider", "icu_provider_blob", @@ -3170,11 +3231,13 @@ version = "0.13.1" dependencies = [ "bytemuck", "comemo", + "hayro", "image", "pixglyph", "resvg", "tiny-skia", "ttf-parser", + "typst-assets", "typst-library", "typst-macros", "typst-timing", @@ -3188,8 +3251,10 @@ dependencies = [ "comemo", "ecow", "flate2", + "hayro", "image", "ttf-parser", + "typst-assets", "typst-library", "typst-macros", "typst-timing", @@ -3586,7 +3651,7 @@ version = "0.221.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9845c470a2e10b61dd42c385839cdd6496363ed63b5c9e420b5488b77bd22083" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", "indexmap 2.7.1", ] @@ -3721,7 +3786,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" dependencies = [ - "bitflags 2.8.0", + "bitflags 2.9.1", ] [[package]] @@ -3929,13 +3994,12 @@ dependencies = [ [[package]] name = "zip" -version = "2.5.0" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" +checksum = "9aed4ac33e8eb078c89e6cbb1d5c4c7703ec6d299fc3e7c3695af8f8b423468b" dependencies = [ "arbitrary", "crc32fast", - "crossbeam-utils", "flate2", "indexmap 2.7.1", "memchr", @@ -3944,9 +4008,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b20717f0917c908dc63de2e44e97f1e6b126ca58d0e391cee86d504eb8fbd05" +checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" [[package]] name = "zopfli" diff --git a/Cargo.toml b/Cargo.toml index 03141cbbf..500d116a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,9 @@ resolver = "2" [workspace.package] version = "0.13.1" -rust-version = "1.83" # also change in ci.yml +rust-version = "1.88" # also change in ci.yml authors = ["The Typst Project Developers"] -edition = "2021" +edition = "2024" homepage = "https://typst.app" repository = "https://github.com/typst/typst" license = "Apache-2.0" @@ -32,8 +32,8 @@ typst-svg = { path = "crates/typst-svg", version = "0.13.1" } typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" } typst-timing = { path = "crates/typst-timing", version = "0.13.1" } typst-utils = { path = "crates/typst-utils", version = "0.13.1" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "c74e539" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "fddbf8b" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "fbf00f9" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "c6c2acf" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" @@ -47,7 +47,7 @@ clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } clap_complete = "4.2.1" clap_mangen = "0.2.10" codespan-reporting = "0.11" -codex = { git = "https://github.com/typst/codex", rev = "56eb217" } +codex = { git = "https://github.com/typst/codex", rev = "9ac86f9" } color-print = "0.3.6" comemo = "0.4" csv = "1" @@ -61,6 +61,8 @@ fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" glidesort = "0.1.2" hayagriva = "0.8.1" +hayro-syntax = { git = "https://github.com/LaurenzV/hayro", rev = "e701f95" } +hayro = { git = "https://github.com/LaurenzV/hayro", rev = "e701f95" } heck = "0.5" hypher = "0.1.4" icu_properties = { version = "1.4", features = ["serde"] } @@ -68,13 +70,12 @@ icu_provider = { version = "1.4", features = ["sync"] } icu_provider_adapters = "1.4" icu_provider_blob = "1.4" icu_segmenter = { version = "1.4", features = ["serde"] } -if_chain = "1" image = { version = "0.25.5", default-features = false, features = ["png", "jpeg", "gif", "webp"] } indexmap = { version = "2", features = ["serde"] } infer = { version = "0.19.0", default-features = false } kamadak-exif = "0.6" -krilla = { version = "0.4.0", default-features = false, features = ["raster-images", "comemo", "rayon"] } -krilla-svg = "0.1.0" +krilla = { git = "https://github.com/LaurenzV/krilla", rev = "37b9a00", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "37b9a00"} kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" @@ -143,7 +144,7 @@ xmlparser = "0.13.5" xmlwriter = "0.1.0" xz2 = { version = "0.1", features = ["static"] } yaml-front-matter = "0.1" -zip = { version = "2.5", default-features = false, features = ["deflate"] } +zip = { version = "4.3", default-features = false, features = ["deflate"] } [profile.dev.package."*"] opt-level = 2 diff --git a/README.md b/README.md index 9526f3df4..675c7bdca 100644 --- a/README.md +++ b/README.md @@ -173,8 +173,11 @@ typst help typst help watch ``` -If you prefer an integrated IDE-like experience with autocompletion and instant -preview, you can also check out [Typst's free web app][app]. +If you prefer an integrated IDE-like experience with autocompletion and instant +preview, you can also check out our [free web app][app]. Alternatively, there is +a community-created language server called +[Tinymist](https://myriad-dreamin.github.io/tinymist/) which is integrated into +various editor extensions. ## Community The main places where the community gathers are our [Forum][forum] and our @@ -240,6 +243,26 @@ instant preview. To achieve these goals, we follow three core design principles: Luckily we have [`comemo`], a system for incremental compilation which does most of the hard work in the background. +## Acknowledgements + +We'd like to thank everyone who is supporting Typst's development, be it via +[GitHub sponsors] or elsewhere. In particular, special thanks[^1] go to: + +- [Posit](https://posit.co/blog/posit-and-typst/) for financing a full-time + compiler engineer +- [NLnet](https://nlnet.nl/) for supporting work on Typst via multiple grants + through the [NGI Zero Core](https://nlnet.nl/core) fund: + - Work on [HTML export](https://nlnet.nl/project/Typst-HTML/) + - Work on [PDF accessibility](https://nlnet.nl/project/Typst-Accessibility/) +- [Science & Startups](https://www.science-startups.berlin/) for having financed + Typst development from January through June 2023 via the Berlin Startup + Scholarship +- [Zerodha](https://zerodha.tech/blog/1-5-million-pdfs-in-25-minutes/) for their + generous one-time sponsorship + +[^1]: This list only includes contributions for our open-source work that exceed + or are expected to exceed €10K. + [docs]: https://typst.app/docs/ [app]: https://typst.app/ [discord]: https://discord.gg/2uDybryKPe @@ -259,3 +282,4 @@ instant preview. To achieve these goals, we follow three core design principles: [packages]: https://github.com/typst/packages/ [`comemo`]: https://github.com/typst/comemo/ [snap]: https://snapcraft.io/typst +[GitHub sponsors]: https://github.com/sponsors/typst/ diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index 7e9b93f93..792cabae1 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -29,6 +29,7 @@ typst-svg = { workspace = true } typst-timing = { workspace = true } chrono = { workspace = true } clap = { workspace = true } +clap_complete = { workspace = true } codespan-reporting = { workspace = true } color-print = { workspace = true } comemo = { workspace = true } diff --git a/crates/typst-cli/build.rs b/crates/typst-cli/build.rs index bd6a563db..23bc2d183 100644 --- a/crates/typst-cli/build.rs +++ b/crates/typst-cli/build.rs @@ -1,10 +1,10 @@ use std::env; -use std::fs::{create_dir_all, File}; +use std::fs::{File, create_dir_all}; use std::path::Path; use std::process::Command; use clap::{CommandFactory, ValueEnum}; -use clap_complete::{generate_to, Shell}; +use clap_complete::{Shell, generate_to}; use clap_mangen::Man; #[path = "src/args.rs"] diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index fd0eb5f05..7459be0f2 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -7,6 +7,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use clap::builder::{TypedValueParser, ValueParser}; use clap::{ArgAction, Args, ColorChoice, Parser, Subcommand, ValueEnum, ValueHint}; +use clap_complete::Shell; use semver::Version; /// The character typically used to separate path components @@ -81,6 +82,9 @@ pub enum Command { /// Self update the Typst CLI. #[cfg_attr(not(feature = "self-update"), clap(hide = true))] Update(UpdateCommand), + + /// Generates shell completion scripts. + Completions(CompletionsCommand), } /// Compiles an input file into a supported output format. @@ -198,6 +202,14 @@ pub struct UpdateCommand { pub backup_path: Option, } +/// Generates shell completion scripts. +#[derive(Debug, Clone, Parser)] +pub struct CompletionsCommand { + /// The shell to generate completions for. + #[arg(value_enum)] + pub shell: Shell, +} + /// Arguments for compilation and watching. #[derive(Debug, Clone, Args)] pub struct CompileArgs { @@ -491,7 +503,7 @@ pub enum PdfStandard { /// PDF/A-2u. #[value(name = "a-2u")] A_2u, - /// PDF/A-3u. + /// PDF/A-3b. #[value(name = "a-3b")] A_3b, /// PDF/A-3u. diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 207bb7d09..30cf1473e 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -10,14 +10,14 @@ use ecow::eco_format; use parking_lot::RwLock; use pathdiff::diff_paths; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use typst::WorldExt; use typst::diag::{ - bail, At, Severity, SourceDiagnostic, SourceResult, StrResult, Warned, + At, Severity, SourceDiagnostic, SourceResult, StrResult, Warned, bail, }; use typst::foundations::{Datetime, Smart}; -use typst::html::HtmlDocument; use typst::layout::{Frame, Page, PageRanges, PagedDocument}; use typst::syntax::{FileId, Lines, Span}; -use typst::WorldExt; +use typst_html::HtmlDocument; use typst_pdf::{PdfOptions, PdfStandards, Timestamp}; use crate::args::{ @@ -513,7 +513,9 @@ fn write_make_deps( }) .collect::, _>>() else { - bail!("failed to create make dependencies file because output path was not valid unicode") + bail!( + "failed to create make dependencies file because output path was not valid unicode" + ) }; if output_paths.is_empty() { bail!("failed to create make dependencies file because output was stdout") diff --git a/crates/typst-cli/src/completions.rs b/crates/typst-cli/src/completions.rs new file mode 100644 index 000000000..51e7db103 --- /dev/null +++ b/crates/typst-cli/src/completions.rs @@ -0,0 +1,13 @@ +use std::io::stdout; + +use clap::CommandFactory; +use clap_complete::generate; + +use crate::args::{CliArguments, CompletionsCommand}; + +/// Execute the completions command. +pub fn completions(command: &CompletionsCommand) { + let mut cmd = CliArguments::command(); + let bin_name = cmd.get_name().to_string(); + generate(command.shell, &mut cmd, bin_name, &mut stdout()); +} diff --git a/crates/typst-cli/src/download.rs b/crates/typst-cli/src/download.rs index ca1e539d4..28317ff72 100644 --- a/crates/typst-cli/src/download.rs +++ b/crates/typst-cli/src/download.rs @@ -8,8 +8,8 @@ use codespan_reporting::term::termcolor::WriteColor; use typst::utils::format_duration; use typst_kit::download::{DownloadState, Downloader, Progress}; -use crate::terminal::{self, TermOut}; use crate::ARGS; +use crate::terminal::{self, TermOut}; /// Prints download progress by writing `downloading {0}` followed by repeatedly /// updating the last terminal line. diff --git a/crates/typst-cli/src/init.rs b/crates/typst-cli/src/init.rs index 2806c7eae..16b6fe99f 100644 --- a/crates/typst-cli/src/init.rs +++ b/crates/typst-cli/src/init.rs @@ -4,7 +4,7 @@ use std::path::Path; use codespan_reporting::term::termcolor::{Color, ColorSpec, WriteColor}; use ecow::eco_format; use fs_extra::dir::CopyOptions; -use typst::diag::{bail, FileError, StrResult}; +use typst::diag::{FileError, StrResult, bail}; use typst::syntax::package::{ PackageManifest, PackageSpec, TemplateInfo, VersionlessPackageSpec, }; diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 14f8a665d..4774dd43b 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -1,5 +1,6 @@ mod args; mod compile; +mod completions; mod download; mod fonts; mod greet; @@ -20,8 +21,8 @@ use std::io::{self, Write}; use std::process::ExitCode; use std::sync::LazyLock; -use clap::error::ErrorKind; use clap::Parser; +use clap::error::ErrorKind; use codespan_reporting::term; use codespan_reporting::term::termcolor::WriteColor; use typst::diag::HintedStrResult; @@ -71,6 +72,7 @@ fn dispatch() -> HintedStrResult<()> { Command::Query(command) => crate::query::query(command)?, Command::Fonts(command) => crate::fonts::fonts(command), Command::Update(command) => crate::update::update(command)?, + Command::Completions(command) => crate::completions::completions(command), } Ok(()) @@ -100,7 +102,7 @@ fn print_error(msg: &str) -> io::Result<()> { #[cfg(not(feature = "self-update"))] mod update { - use typst::diag::{bail, StrResult}; + use typst::diag::{StrResult, bail}; use crate::args::UpdateCommand; diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index 7806e456f..30688b3b6 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -1,13 +1,13 @@ use comemo::Track; -use ecow::{eco_format, EcoString}; +use ecow::{EcoString, eco_format}; use serde::Serialize; -use typst::diag::{bail, HintedStrResult, StrResult, Warned}; +use typst::World; +use typst::diag::{HintedStrResult, StrResult, Warned, bail}; use typst::engine::Sink; use typst::foundations::{Content, IntoValue, LocatableSelector, Scope}; use typst::layout::PagedDocument; -use typst::syntax::Span; -use typst::World; -use typst_eval::{eval_string, EvalMode}; +use typst::syntax::{Span, SyntaxMode}; +use typst_eval::eval_string; use crate::args::{QueryCommand, SerializationFormat}; use crate::compile::print_diagnostics; @@ -63,7 +63,7 @@ fn retrieve( Sink::new().track_mut(), &command.selector, Span::detached(), - EvalMode::Code, + SyntaxMode::Code, Scope::default(), ) .map_err(|errors| { diff --git a/crates/typst-cli/src/server.rs b/crates/typst-cli/src/server.rs index 8910e0323..0dde19f7f 100644 --- a/crates/typst-cli/src/server.rs +++ b/crates/typst-cli/src/server.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use ecow::eco_format; use parking_lot::{Condvar, Mutex, MutexGuard}; use tiny_http::{Header, Request, Response, StatusCode}; -use typst::diag::{bail, StrResult}; +use typst::diag::{StrResult, bail}; use crate::args::{Input, ServerArgs}; @@ -162,7 +162,7 @@ impl Bucket { } /// Retrieves the current data in the bucket. - fn get(&self) -> MutexGuard { + fn get(&self) -> MutexGuard<'_, T> { self.mutex.lock() } diff --git a/crates/typst-cli/src/timings.rs b/crates/typst-cli/src/timings.rs index 3d10bbc67..89b9523bf 100644 --- a/crates/typst-cli/src/timings.rs +++ b/crates/typst-cli/src/timings.rs @@ -2,9 +2,9 @@ use std::fs::File; use std::io::BufWriter; use std::path::{Path, PathBuf}; -use typst::diag::{bail, StrResult}; -use typst::syntax::Span; use typst::World; +use typst::diag::{StrResult, bail}; +use typst::syntax::Span; use crate::args::{CliArguments, Command}; use crate::world::SystemWorld; diff --git a/crates/typst-cli/src/update.rs b/crates/typst-cli/src/update.rs index ec8ca71e8..ec074c6c4 100644 --- a/crates/typst-cli/src/update.rs +++ b/crates/typst-cli/src/update.rs @@ -6,7 +6,7 @@ use ecow::eco_format; use semver::Version; use serde::Deserialize; use tempfile::NamedTempFile; -use typst::diag::{bail, StrResult}; +use typst::diag::{StrResult, bail}; use typst_kit::download::Downloader; use xz2::bufread::XzDecoder; use zip::ZipArchive; diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 630d340b2..765f86808 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -10,12 +10,12 @@ use codespan_reporting::term::{self, termcolor}; use ecow::eco_format; use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as _}; use same_file::is_same_file; -use typst::diag::{bail, warning, StrResult}; +use typst::diag::{StrResult, bail, warning}; use typst::syntax::Span; use typst::utils::format_duration; use crate::args::{Input, Output, WatchCommand}; -use crate::compile::{compile_once, print_diagnostics, CompileConfig}; +use crate::compile::{CompileConfig, compile_once, print_diagnostics}; use crate::timings::Timer; use crate::world::{SystemWorld, WorldCreationError}; use crate::{print_error, terminal}; diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 3dd0a6337..f44dd6c65 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -5,14 +5,14 @@ use std::sync::{LazyLock, OnceLock}; use std::{fmt, fs, io, mem}; use chrono::{DateTime, Datelike, FixedOffset, Local, Utc}; -use ecow::{eco_format, EcoString}; +use ecow::{EcoString, eco_format}; use parking_lot::Mutex; use typst::diag::{FileError, FileResult}; use typst::foundations::{Bytes, Datetime, Dict, IntoValue}; use typst::syntax::{FileId, Lines, Source, VirtualPath}; use typst::text::{Font, FontBook}; use typst::utils::LazyHash; -use typst::{Library, World}; +use typst::{Library, LibraryExt, World}; use typst_kit::fonts::{FontSlot, Fonts}; use typst_kit::package::PackageStorage; use typst_timing::timed; @@ -361,10 +361,10 @@ impl SlotCell { f: impl FnOnce(Vec, Option) -> FileResult, ) -> FileResult { // If we accessed the file already in this compilation, retrieve it. - if mem::replace(&mut self.accessed, true) { - if let Some(data) = &self.data { - return data.clone(); - } + if mem::replace(&mut self.accessed, true) + && let Some(data) = &self.data + { + return data.clone(); } // Read and hash the file. @@ -372,10 +372,10 @@ impl SlotCell { let fingerprint = timed!("hashing file", typst::utils::hash128(&result)); // If the file contents didn't change, yield the old processed data. - if mem::replace(&mut self.fingerprint, fingerprint) == fingerprint { - if let Some(data) = &self.data { - return data.clone(); - } + if mem::replace(&mut self.fingerprint, fingerprint) == fingerprint + && let Some(data) = &self.data + { + return data.clone(); } let prev = self.data.take().and_then(Result::ok); diff --git a/crates/typst-eval/Cargo.toml b/crates/typst-eval/Cargo.toml index 12a6a6a46..b39382ffe 100644 --- a/crates/typst-eval/Cargo.toml +++ b/crates/typst-eval/Cargo.toml @@ -20,7 +20,6 @@ typst-timing = { workspace = true } typst-utils = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } -if_chain = { workspace = true } indexmap = { workspace = true } toml = { workspace = true } unicode-segmentation = { workspace = true } diff --git a/crates/typst-eval/src/access.rs b/crates/typst-eval/src/access.rs index 22a6b7f3d..c82b9339a 100644 --- a/crates/typst-eval/src/access.rs +++ b/crates/typst-eval/src/access.rs @@ -1,9 +1,9 @@ use ecow::eco_format; -use typst_library::diag::{bail, At, Hint, SourceResult, Trace, Tracepoint}; +use typst_library::diag::{At, Hint, SourceResult, Trace, Tracepoint, bail}; use typst_library::foundations::{Dict, Value}; use typst_syntax::ast::{self, AstNode}; -use crate::{call_method_access, is_accessor_method, Eval, Vm}; +use crate::{Eval, Vm, call_method_access, is_accessor_method}; /// Access an expression mutably. pub(crate) trait Access { @@ -29,10 +29,10 @@ 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(); - if vm.inspected == Some(span) { - if let Ok(binding) = vm.scopes.get(&self) { - vm.trace(binding.read().clone()); - } + if vm.inspected == Some(span) + && let Ok(binding) = vm.scopes.get(&self) + { + vm.trace(binding.read().clone()); } vm.scopes .get_mut(&self) diff --git a/crates/typst-eval/src/binding.rs b/crates/typst-eval/src/binding.rs index f3802f079..9a53ac069 100644 --- a/crates/typst-eval/src/binding.rs +++ b/crates/typst-eval/src/binding.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use ecow::eco_format; -use typst_library::diag::{bail, error, At, SourceDiagnostic, SourceResult}; +use typst_library::diag::{At, SourceDiagnostic, SourceResult, bail, error}; use typst_library::foundations::{Array, Dict, Value}; use typst_syntax::ast::{self, AstNode}; diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs index eaeabbab3..c4087f6bb 100644 --- a/crates/typst-eval/src/call.rs +++ b/crates/typst-eval/src/call.rs @@ -1,8 +1,9 @@ use comemo::{Tracked, TrackedMut}; -use ecow::{eco_format, EcoString, EcoVec}; +use ecow::{EcoString, EcoVec, eco_format}; +use typst_library::World; use typst_library::diag::{ - bail, error, At, HintedStrResult, HintedString, SourceDiagnostic, SourceResult, - Trace, Tracepoint, + At, HintedStrResult, HintedString, SourceDiagnostic, SourceResult, Trace, Tracepoint, + bail, error, }; use typst_library::engine::{Engine, Sink, Traced}; use typst_library::foundations::{ @@ -12,12 +13,11 @@ use typst_library::foundations::{ use typst_library::introspection::Introspector; use typst_library::math::LrElem; use typst_library::routines::Routines; -use typst_library::World; use typst_syntax::ast::{self, AstNode, Ident}; use typst_syntax::{Span, Spanned, SyntaxNode}; use typst_utils::LazyHash; -use crate::{call_method_mut, is_mutating_method, Access, Eval, FlowEvent, Route, Vm}; +use crate::{Access, Eval, FlowEvent, Route, Vm, call_method_mut, is_mutating_method}; impl Eval for ast::FuncCall<'_> { type Output = Value; diff --git a/crates/typst-eval/src/code.rs b/crates/typst-eval/src/code.rs index 9078418e4..d3e0c937f 100644 --- a/crates/typst-eval/src/code.rs +++ b/crates/typst-eval/src/code.rs @@ -1,9 +1,9 @@ -use ecow::{eco_vec, EcoVec}; -use typst_library::diag::{bail, error, warning, At, SourceResult}; +use ecow::{EcoVec, eco_vec}; +use typst_library::diag::{At, SourceResult, bail, error, warning}; use typst_library::engine::Engine; use typst_library::foundations::{ - ops, Array, Capturer, Closure, Content, ContextElem, Dict, Func, NativeElement, - Selector, Str, Value, + Array, Capturer, Closure, Content, ContextElem, Dict, Func, NativeElement, Selector, + Str, Value, ops, }; use typst_library::introspection::{Counter, State}; use typst_syntax::ast::{self, AstNode}; @@ -324,21 +324,17 @@ impl Eval for ast::FieldAccess<'_> { }; // Check whether this is a get rule field access. - if_chain::if_chain! { - if let Value::Func(func) = &value; - if let Some(element) = func.element(); - if let Some(id) = element.field_id(&field); - let styles = vm.context.styles().at(field.span()); - if let Ok(value) = element.field_from_styles( - id, - styles.as_ref().map(|&s| s).unwrap_or_default(), - ); - then { - // Only validate the context once we know that this is indeed - // a field from the style chain. - let _ = styles?; - return Ok(value); - } + if let Value::Func(func) = &value + && let Some(element) = func.element() + && let Some(id) = element.field_id(&field) + && let styles = vm.context.styles().at(field.span()) + && let Ok(value) = element + .field_from_styles(id, styles.as_ref().map(|&s| s).unwrap_or_default()) + { + // Only validate the context once we know that this is indeed + // a field from the style chain. + let _ = styles?; + return Ok(value); } Err(err) diff --git a/crates/typst-eval/src/flow.rs b/crates/typst-eval/src/flow.rs index b5ba487f5..dcc2073bd 100644 --- a/crates/typst-eval/src/flow.rs +++ b/crates/typst-eval/src/flow.rs @@ -1,10 +1,10 @@ -use typst_library::diag::{bail, error, At, SourceDiagnostic, SourceResult}; -use typst_library::foundations::{ops, IntoValue, Value}; +use typst_library::diag::{At, SourceDiagnostic, SourceResult, bail, error}; +use typst_library::foundations::{IntoValue, Value, ops}; use typst_syntax::ast::{self, AstNode}; use typst_syntax::{Span, SyntaxKind, SyntaxNode}; use unicode_segmentation::UnicodeSegmentation; -use crate::{destructure, Eval, Vm}; +use crate::{Eval, Vm, destructure}; /// The maximum number of loop iterations. const MAX_ITERATIONS: usize = 10_000; diff --git a/crates/typst-eval/src/import.rs b/crates/typst-eval/src/import.rs index 1b1641487..1e091a9cf 100644 --- a/crates/typst-eval/src/import.rs +++ b/crates/typst-eval/src/import.rs @@ -1,16 +1,16 @@ use comemo::TrackedMut; -use ecow::{eco_format, eco_vec, EcoString}; +use ecow::{EcoString, eco_format, eco_vec}; +use typst_library::World; use typst_library::diag::{ - bail, error, warning, At, FileError, SourceResult, Trace, Tracepoint, + At, FileError, SourceResult, Trace, Tracepoint, bail, error, warning, }; use typst_library::engine::Engine; use typst_library::foundations::{Binding, Content, Module, Value}; -use typst_library::World; use typst_syntax::ast::{self, AstNode, BareImportError}; use typst_syntax::package::{PackageManifest, PackageSpec}; use typst_syntax::{FileId, Span, VirtualPath}; -use crate::{eval, Eval, Vm}; +use crate::{Eval, Vm, eval}; impl Eval for ast::ModuleImport<'_> { type Output = Value; @@ -46,14 +46,14 @@ impl Eval for ast::ModuleImport<'_> { // If there is a rename, import the source itself under that name. let new_name = self.new_name(); if let Some(new_name) = new_name { - if let ast::Expr::Ident(ident) = self.source() { - if ident.as_str() == new_name.as_str() { - // Warn on `import x as x` - vm.engine.sink.warn(warning!( - new_name.span(), - "unnecessary import rename to same name", - )); - } + if let ast::Expr::Ident(ident) = self.source() + && ident.as_str() == new_name.as_str() + { + // Warn on `import x as x` + vm.engine.sink.warn(warning!( + new_name.span(), + "unnecessary import rename to same name", + )); } // Define renamed module on the scope. @@ -142,15 +142,14 @@ impl Eval for ast::ModuleImport<'_> { // it. // Warn on `import ...: x as x` - if let ast::ImportItem::Renamed(renamed_item) = &item { - if renamed_item.original_name().as_str() + if let ast::ImportItem::Renamed(renamed_item) = &item + && renamed_item.original_name().as_str() == renamed_item.new_name().as_str() - { - vm.engine.sink.warn(warning!( - renamed_item.new_name().span(), - "unnecessary import rename to same name", - )); - } + { + vm.engine.sink.warn(warning!( + renamed_item.new_name().span(), + "unnecessary import rename to same name", + )); } vm.bind(item.bound_name(), binding.clone()); diff --git a/crates/typst-eval/src/lib.rs b/crates/typst-eval/src/lib.rs index 586da26be..965b37c96 100644 --- a/crates/typst-eval/src/lib.rs +++ b/crates/typst-eval/src/lib.rs @@ -14,25 +14,24 @@ mod methods; mod rules; mod vm; -pub use self::call::{eval_closure, CapturesVisitor}; +pub use self::call::{CapturesVisitor, eval_closure}; pub use self::flow::FlowEvent; pub use self::import::import; pub use self::vm::Vm; -pub use typst_library::routines::EvalMode; use self::access::*; use self::binding::*; use self::methods::*; use comemo::{Track, Tracked, TrackedMut}; -use typst_library::diag::{bail, SourceResult}; +use typst_library::World; +use typst_library::diag::{SourceResult, bail}; use typst_library::engine::{Engine, Route, Sink, Traced}; use typst_library::foundations::{Context, Module, NativeElement, Scope, Scopes, Value}; use typst_library::introspection::Introspector; use typst_library::math::EquationElem; use typst_library::routines::Routines; -use typst_library::World; -use typst_syntax::{ast, parse, parse_code, parse_math, Source, Span}; +use typst_syntax::{Source, Span, SyntaxMode, ast, parse, parse_code, parse_math}; /// Evaluate a source file and return the resulting module. #[comemo::memoize] @@ -104,13 +103,13 @@ pub fn eval_string( sink: TrackedMut, string: &str, span: Span, - mode: EvalMode, + mode: SyntaxMode, scope: Scope, ) -> SourceResult { let mut root = match mode { - EvalMode::Code => parse_code(string), - EvalMode::Markup => parse(string), - EvalMode::Math => parse_math(string), + SyntaxMode::Code => parse_code(string), + SyntaxMode::Markup => parse(string), + SyntaxMode::Math => parse_math(string), }; root.synthesize(span); @@ -141,11 +140,11 @@ pub fn eval_string( // Evaluate the code. let output = match mode { - EvalMode::Code => root.cast::().unwrap().eval(&mut vm)?, - EvalMode::Markup => { + SyntaxMode::Code => root.cast::().unwrap().eval(&mut vm)?, + SyntaxMode::Markup => { Value::Content(root.cast::().unwrap().eval(&mut vm)?) } - EvalMode::Math => Value::Content( + SyntaxMode::Math => Value::Content( EquationElem::new(root.cast::().unwrap().eval(&mut vm)?) .with_block(false) .pack() diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index 5beefa912..772494b61 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -1,4 +1,4 @@ -use typst_library::diag::{warning, At, SourceResult}; +use typst_library::diag::{At, SourceResult, warning}; use typst_library::foundations::{ Content, Label, NativeElement, Repr, Smart, Symbol, Unlabellable, Value, }; @@ -186,7 +186,7 @@ impl Eval for ast::Raw<'_> { let lines = self.lines().map(|line| (line.get().clone(), line.span())).collect(); let mut elem = RawElem::new(RawContent::Lines(lines)).with_block(self.block()); if let Some(lang) = self.lang() { - elem.push_lang(Some(lang.get().clone())); + elem.lang.set(Some(lang.get().clone())); } Ok(elem.pack()) } @@ -205,7 +205,9 @@ impl Eval for ast::Label<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Label(Label::new(PicoStr::intern(self.get())))) + Ok(Value::Label( + Label::new(PicoStr::intern(self.get())).expect("unexpected empty label"), + )) } } @@ -213,12 +215,12 @@ impl Eval for ast::Ref<'_> { type Output = Content; fn eval(self, vm: &mut Vm) -> SourceResult { - let target = Label::new(PicoStr::intern(self.target())); + let target = Label::new(PicoStr::intern(self.target())) + .expect("unexpected empty reference"); let mut elem = RefElem::new(target); if let Some(supplement) = self.supplement() { - elem.push_supplement(Smart::Custom(Some(Supplement::Content( - supplement.eval(vm)?, - )))); + elem.supplement + .set(Smart::Custom(Some(Supplement::Content(supplement.eval(vm)?)))); } Ok(elem.pack()) } @@ -249,7 +251,7 @@ impl Eval for ast::EnumItem<'_> { let body = self.body().eval(vm)?; let mut elem = EnumItem::new(body); if let Some(number) = self.number() { - elem.push_number(Some(number)); + elem.number.set(Smart::Custom(number)); } Ok(elem.pack()) } diff --git a/crates/typst-eval/src/math.rs b/crates/typst-eval/src/math.rs index 0e271a089..c2325a8c5 100644 --- a/crates/typst-eval/src/math.rs +++ b/crates/typst-eval/src/math.rs @@ -80,17 +80,17 @@ impl Eval for ast::MathAttach<'_> { let mut elem = AttachElem::new(base); if let Some(expr) = self.top() { - elem.push_t(Some(expr.eval_display(vm)?)); + elem.t.set(Some(expr.eval_display(vm)?)); } // Always attach primes in scripts style (not limits style), // i.e. at the top-right corner. if let Some(primes) = self.primes() { - elem.push_tr(Some(primes.eval(vm)?)); + elem.tr.set(Some(primes.eval(vm)?)); } if let Some(expr) = self.bottom() { - elem.push_b(Some(expr.eval_display(vm)?)); + elem.b.set(Some(expr.eval_display(vm)?)); } Ok(elem.pack()) diff --git a/crates/typst-eval/src/ops.rs b/crates/typst-eval/src/ops.rs index ebbd67430..af129411c 100644 --- a/crates/typst-eval/src/ops.rs +++ b/crates/typst-eval/src/ops.rs @@ -1,8 +1,8 @@ use typst_library::diag::{At, HintedStrResult, SourceResult}; -use typst_library::foundations::{ops, IntoValue, Value}; +use typst_library::foundations::{IntoValue, Value, ops}; use typst_syntax::ast::{self, AstNode}; -use crate::{access_dict, Access, Eval, Vm}; +use crate::{Access, Eval, Vm, access_dict}; impl Eval for ast::Unary<'_> { type Output = Value; @@ -76,12 +76,12 @@ fn apply_assignment( // An assignment to a dictionary field is different from a normal access // since it can create the field instead of just modifying it. - if binary.op() == ast::BinOp::Assign { - if let ast::Expr::FieldAccess(access) = lhs { - let dict = access_dict(vm, access)?; - dict.insert(access.field().get().clone().into(), rhs); - return Ok(Value::None); - } + if binary.op() == ast::BinOp::Assign + && let ast::Expr::FieldAccess(access) = lhs + { + let dict = access_dict(vm, access)?; + dict.insert(access.field().get().clone().into(), rhs); + return Ok(Value::None); } let location = binary.lhs().access(vm)?; diff --git a/crates/typst-eval/src/rules.rs b/crates/typst-eval/src/rules.rs index f4c1563f3..011478a1e 100644 --- a/crates/typst-eval/src/rules.rs +++ b/crates/typst-eval/src/rules.rs @@ -1,6 +1,6 @@ -use typst_library::diag::{warning, At, SourceResult}; +use typst_library::diag::{At, SourceResult, warning}; use typst_library::foundations::{ - Element, Fields, Func, Recipe, Selector, ShowableSelector, Styles, Transformation, + Element, Func, Recipe, Selector, ShowableSelector, Styles, Transformation, }; use typst_library::layout::BlockElem; use typst_library::model::ParElem; @@ -12,10 +12,10 @@ impl Eval for ast::SetRule<'_> { type Output = Styles; fn eval(self, vm: &mut Vm) -> SourceResult { - if let Some(condition) = self.condition() { - if !condition.eval(vm)?.cast::().at(condition.span())? { - return Ok(Styles::new()); - } + if let Some(condition) = self.condition() + && !condition.eval(vm)?.cast::().at(condition.span())? + { + return Ok(Styles::new()); } let target = self.target(); @@ -58,19 +58,16 @@ impl Eval for ast::ShowRule<'_> { /// Migration hint for `show par: set block(spacing: ..)`. fn check_show_par_set_block(vm: &mut Vm, recipe: &Recipe) { - if_chain::if_chain! { - if let Some(Selector::Elem(elem, _)) = recipe.selector(); - if *elem == Element::of::(); - if let Transformation::Style(styles) = recipe.transform(); - if styles.has::(::Enum::Above as _) || - styles.has::(::Enum::Below as _); - then { - vm.engine.sink.warn(warning!( + if let Some(Selector::Elem(elem, _)) = recipe.selector() + && *elem == Element::of::() + && let Transformation::Style(styles) = recipe.transform() + && (styles.has(BlockElem::above) || styles.has(BlockElem::below)) + { + vm.engine.sink.warn(warning!( recipe.span(), "`show par: set block(spacing: ..)` has no effect anymore"; hint: "write `set par(spacing: ..)` instead"; hint: "this is specific to paragraphs as they are not considered blocks anymore" )) - } } } diff --git a/crates/typst-eval/src/vm.rs b/crates/typst-eval/src/vm.rs index 52cfb4b5b..2c62c8b40 100644 --- a/crates/typst-eval/src/vm.rs +++ b/crates/typst-eval/src/vm.rs @@ -1,10 +1,10 @@ use comemo::Tracked; +use typst_library::World; use typst_library::diag::warning; use typst_library::engine::Engine; use typst_library::foundations::{Binding, Context, IntoValue, Scopes, Value}; -use typst_library::World; -use typst_syntax::ast::{self, AstNode}; use typst_syntax::Span; +use typst_syntax::ast::{self, AstNode}; use crate::FlowEvent; diff --git a/crates/typst-html/Cargo.toml b/crates/typst-html/Cargo.toml index 534848f96..54cad0124 100644 --- a/crates/typst-html/Cargo.toml +++ b/crates/typst-html/Cargo.toml @@ -13,14 +13,18 @@ keywords = { workspace = true } readme = { workspace = true } [dependencies] +typst-assets = { workspace = true } typst-library = { workspace = true } typst-macros = { workspace = true } typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } typst-svg = { workspace = true } +bumpalo = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } +palette = { workspace = true } +time = { workspace = true } [lints] workspace = true diff --git a/crates/typst-html/src/attr.rs b/crates/typst-html/src/attr.rs new file mode 100644 index 000000000..0fec3955d --- /dev/null +++ b/crates/typst-html/src/attr.rs @@ -0,0 +1,195 @@ + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(non_upper_case_globals)] +#![allow(dead_code)] + +use crate::HtmlAttr; + +pub const abbr: HtmlAttr = HtmlAttr::constant("abbr"); +pub const accept: HtmlAttr = HtmlAttr::constant("accept"); +pub const accept_charset: HtmlAttr = HtmlAttr::constant("accept-charset"); +pub const accesskey: HtmlAttr = HtmlAttr::constant("accesskey"); +pub const action: HtmlAttr = HtmlAttr::constant("action"); +pub const allow: HtmlAttr = HtmlAttr::constant("allow"); +pub const allowfullscreen: HtmlAttr = HtmlAttr::constant("allowfullscreen"); +pub const alpha: HtmlAttr = HtmlAttr::constant("alpha"); +pub const alt: HtmlAttr = HtmlAttr::constant("alt"); +pub const aria_activedescendant: HtmlAttr = HtmlAttr::constant("aria-activedescendant"); +pub const aria_atomic: HtmlAttr = HtmlAttr::constant("aria-atomic"); +pub const aria_autocomplete: HtmlAttr = HtmlAttr::constant("aria-autocomplete"); +pub const aria_busy: HtmlAttr = HtmlAttr::constant("aria-busy"); +pub const aria_checked: HtmlAttr = HtmlAttr::constant("aria-checked"); +pub const aria_colcount: HtmlAttr = HtmlAttr::constant("aria-colcount"); +pub const aria_colindex: HtmlAttr = HtmlAttr::constant("aria-colindex"); +pub const aria_colspan: HtmlAttr = HtmlAttr::constant("aria-colspan"); +pub const aria_controls: HtmlAttr = HtmlAttr::constant("aria-controls"); +pub const aria_current: HtmlAttr = HtmlAttr::constant("aria-current"); +pub const aria_describedby: HtmlAttr = HtmlAttr::constant("aria-describedby"); +pub const aria_details: HtmlAttr = HtmlAttr::constant("aria-details"); +pub const aria_disabled: HtmlAttr = HtmlAttr::constant("aria-disabled"); +pub const aria_errormessage: HtmlAttr = HtmlAttr::constant("aria-errormessage"); +pub const aria_expanded: HtmlAttr = HtmlAttr::constant("aria-expanded"); +pub const aria_flowto: HtmlAttr = HtmlAttr::constant("aria-flowto"); +pub const aria_haspopup: HtmlAttr = HtmlAttr::constant("aria-haspopup"); +pub const aria_hidden: HtmlAttr = HtmlAttr::constant("aria-hidden"); +pub const aria_invalid: HtmlAttr = HtmlAttr::constant("aria-invalid"); +pub const aria_keyshortcuts: HtmlAttr = HtmlAttr::constant("aria-keyshortcuts"); +pub const aria_label: HtmlAttr = HtmlAttr::constant("aria-label"); +pub const aria_labelledby: HtmlAttr = HtmlAttr::constant("aria-labelledby"); +pub const aria_level: HtmlAttr = HtmlAttr::constant("aria-level"); +pub const aria_live: HtmlAttr = HtmlAttr::constant("aria-live"); +pub const aria_modal: HtmlAttr = HtmlAttr::constant("aria-modal"); +pub const aria_multiline: HtmlAttr = HtmlAttr::constant("aria-multiline"); +pub const aria_multiselectable: HtmlAttr = HtmlAttr::constant("aria-multiselectable"); +pub const aria_orientation: HtmlAttr = HtmlAttr::constant("aria-orientation"); +pub const aria_owns: HtmlAttr = HtmlAttr::constant("aria-owns"); +pub const aria_placeholder: HtmlAttr = HtmlAttr::constant("aria-placeholder"); +pub const aria_posinset: HtmlAttr = HtmlAttr::constant("aria-posinset"); +pub const aria_pressed: HtmlAttr = HtmlAttr::constant("aria-pressed"); +pub const aria_readonly: HtmlAttr = HtmlAttr::constant("aria-readonly"); +pub const aria_relevant: HtmlAttr = HtmlAttr::constant("aria-relevant"); +pub const aria_required: HtmlAttr = HtmlAttr::constant("aria-required"); +pub const aria_roledescription: HtmlAttr = HtmlAttr::constant("aria-roledescription"); +pub const aria_rowcount: HtmlAttr = HtmlAttr::constant("aria-rowcount"); +pub const aria_rowindex: HtmlAttr = HtmlAttr::constant("aria-rowindex"); +pub const aria_rowspan: HtmlAttr = HtmlAttr::constant("aria-rowspan"); +pub const aria_selected: HtmlAttr = HtmlAttr::constant("aria-selected"); +pub const aria_setsize: HtmlAttr = HtmlAttr::constant("aria-setsize"); +pub const aria_sort: HtmlAttr = HtmlAttr::constant("aria-sort"); +pub const aria_valuemax: HtmlAttr = HtmlAttr::constant("aria-valuemax"); +pub const aria_valuemin: HtmlAttr = HtmlAttr::constant("aria-valuemin"); +pub const aria_valuenow: HtmlAttr = HtmlAttr::constant("aria-valuenow"); +pub const aria_valuetext: HtmlAttr = HtmlAttr::constant("aria-valuetext"); +pub const r#as: HtmlAttr = HtmlAttr::constant("as"); +pub const r#async: HtmlAttr = HtmlAttr::constant("async"); +pub const autocapitalize: HtmlAttr = HtmlAttr::constant("autocapitalize"); +pub const autocomplete: HtmlAttr = HtmlAttr::constant("autocomplete"); +pub const autocorrect: HtmlAttr = HtmlAttr::constant("autocorrect"); +pub const autofocus: HtmlAttr = HtmlAttr::constant("autofocus"); +pub const autoplay: HtmlAttr = HtmlAttr::constant("autoplay"); +pub const blocking: HtmlAttr = HtmlAttr::constant("blocking"); +pub const charset: HtmlAttr = HtmlAttr::constant("charset"); +pub const checked: HtmlAttr = HtmlAttr::constant("checked"); +pub const cite: HtmlAttr = HtmlAttr::constant("cite"); +pub const class: HtmlAttr = HtmlAttr::constant("class"); +pub const closedby: HtmlAttr = HtmlAttr::constant("closedby"); +pub const color: HtmlAttr = HtmlAttr::constant("color"); +pub const colorspace: HtmlAttr = HtmlAttr::constant("colorspace"); +pub const cols: HtmlAttr = HtmlAttr::constant("cols"); +pub const colspan: HtmlAttr = HtmlAttr::constant("colspan"); +pub const command: HtmlAttr = HtmlAttr::constant("command"); +pub const commandfor: HtmlAttr = HtmlAttr::constant("commandfor"); +pub const content: HtmlAttr = HtmlAttr::constant("content"); +pub const contenteditable: HtmlAttr = HtmlAttr::constant("contenteditable"); +pub const controls: HtmlAttr = HtmlAttr::constant("controls"); +pub const coords: HtmlAttr = HtmlAttr::constant("coords"); +pub const crossorigin: HtmlAttr = HtmlAttr::constant("crossorigin"); +pub const data: HtmlAttr = HtmlAttr::constant("data"); +pub const datetime: HtmlAttr = HtmlAttr::constant("datetime"); +pub const decoding: HtmlAttr = HtmlAttr::constant("decoding"); +pub const default: HtmlAttr = HtmlAttr::constant("default"); +pub const defer: HtmlAttr = HtmlAttr::constant("defer"); +pub const dir: HtmlAttr = HtmlAttr::constant("dir"); +pub const dirname: HtmlAttr = HtmlAttr::constant("dirname"); +pub const disabled: HtmlAttr = HtmlAttr::constant("disabled"); +pub const download: HtmlAttr = HtmlAttr::constant("download"); +pub const draggable: HtmlAttr = HtmlAttr::constant("draggable"); +pub const enctype: HtmlAttr = HtmlAttr::constant("enctype"); +pub const enterkeyhint: HtmlAttr = HtmlAttr::constant("enterkeyhint"); +pub const fetchpriority: HtmlAttr = HtmlAttr::constant("fetchpriority"); +pub const r#for: HtmlAttr = HtmlAttr::constant("for"); +pub const form: HtmlAttr = HtmlAttr::constant("form"); +pub const formaction: HtmlAttr = HtmlAttr::constant("formaction"); +pub const formenctype: HtmlAttr = HtmlAttr::constant("formenctype"); +pub const formmethod: HtmlAttr = HtmlAttr::constant("formmethod"); +pub const formnovalidate: HtmlAttr = HtmlAttr::constant("formnovalidate"); +pub const formtarget: HtmlAttr = HtmlAttr::constant("formtarget"); +pub const headers: HtmlAttr = HtmlAttr::constant("headers"); +pub const height: HtmlAttr = HtmlAttr::constant("height"); +pub const hidden: HtmlAttr = HtmlAttr::constant("hidden"); +pub const high: HtmlAttr = HtmlAttr::constant("high"); +pub const href: HtmlAttr = HtmlAttr::constant("href"); +pub const hreflang: HtmlAttr = HtmlAttr::constant("hreflang"); +pub const http_equiv: HtmlAttr = HtmlAttr::constant("http-equiv"); +pub const id: HtmlAttr = HtmlAttr::constant("id"); +pub const imagesizes: HtmlAttr = HtmlAttr::constant("imagesizes"); +pub const imagesrcset: HtmlAttr = HtmlAttr::constant("imagesrcset"); +pub const inert: HtmlAttr = HtmlAttr::constant("inert"); +pub const inputmode: HtmlAttr = HtmlAttr::constant("inputmode"); +pub const integrity: HtmlAttr = HtmlAttr::constant("integrity"); +pub const is: HtmlAttr = HtmlAttr::constant("is"); +pub const ismap: HtmlAttr = HtmlAttr::constant("ismap"); +pub const itemid: HtmlAttr = HtmlAttr::constant("itemid"); +pub const itemprop: HtmlAttr = HtmlAttr::constant("itemprop"); +pub const itemref: HtmlAttr = HtmlAttr::constant("itemref"); +pub const itemscope: HtmlAttr = HtmlAttr::constant("itemscope"); +pub const itemtype: HtmlAttr = HtmlAttr::constant("itemtype"); +pub const kind: HtmlAttr = HtmlAttr::constant("kind"); +pub const label: HtmlAttr = HtmlAttr::constant("label"); +pub const lang: HtmlAttr = HtmlAttr::constant("lang"); +pub const list: HtmlAttr = HtmlAttr::constant("list"); +pub const loading: HtmlAttr = HtmlAttr::constant("loading"); +pub const r#loop: HtmlAttr = HtmlAttr::constant("loop"); +pub const low: HtmlAttr = HtmlAttr::constant("low"); +pub const max: HtmlAttr = HtmlAttr::constant("max"); +pub const maxlength: HtmlAttr = HtmlAttr::constant("maxlength"); +pub const media: HtmlAttr = HtmlAttr::constant("media"); +pub const method: HtmlAttr = HtmlAttr::constant("method"); +pub const min: HtmlAttr = HtmlAttr::constant("min"); +pub const minlength: HtmlAttr = HtmlAttr::constant("minlength"); +pub const multiple: HtmlAttr = HtmlAttr::constant("multiple"); +pub const muted: HtmlAttr = HtmlAttr::constant("muted"); +pub const name: HtmlAttr = HtmlAttr::constant("name"); +pub const nomodule: HtmlAttr = HtmlAttr::constant("nomodule"); +pub const nonce: HtmlAttr = HtmlAttr::constant("nonce"); +pub const novalidate: HtmlAttr = HtmlAttr::constant("novalidate"); +pub const open: HtmlAttr = HtmlAttr::constant("open"); +pub const optimum: HtmlAttr = HtmlAttr::constant("optimum"); +pub const pattern: HtmlAttr = HtmlAttr::constant("pattern"); +pub const ping: HtmlAttr = HtmlAttr::constant("ping"); +pub const placeholder: HtmlAttr = HtmlAttr::constant("placeholder"); +pub const playsinline: HtmlAttr = HtmlAttr::constant("playsinline"); +pub const popover: HtmlAttr = HtmlAttr::constant("popover"); +pub const popovertarget: HtmlAttr = HtmlAttr::constant("popovertarget"); +pub const popovertargetaction: HtmlAttr = HtmlAttr::constant("popovertargetaction"); +pub const poster: HtmlAttr = HtmlAttr::constant("poster"); +pub const preload: HtmlAttr = HtmlAttr::constant("preload"); +pub const readonly: HtmlAttr = HtmlAttr::constant("readonly"); +pub const referrerpolicy: HtmlAttr = HtmlAttr::constant("referrerpolicy"); +pub const rel: HtmlAttr = HtmlAttr::constant("rel"); +pub const required: HtmlAttr = HtmlAttr::constant("required"); +pub const reversed: HtmlAttr = HtmlAttr::constant("reversed"); +pub const role: HtmlAttr = HtmlAttr::constant("role"); +pub const rows: HtmlAttr = HtmlAttr::constant("rows"); +pub const rowspan: HtmlAttr = HtmlAttr::constant("rowspan"); +pub const sandbox: HtmlAttr = HtmlAttr::constant("sandbox"); +pub const scope: HtmlAttr = HtmlAttr::constant("scope"); +pub const selected: HtmlAttr = HtmlAttr::constant("selected"); +pub const shadowrootclonable: HtmlAttr = HtmlAttr::constant("shadowrootclonable"); +pub const shadowrootcustomelementregistry: HtmlAttr = HtmlAttr::constant("shadowrootcustomelementregistry"); +pub const shadowrootdelegatesfocus: HtmlAttr = HtmlAttr::constant("shadowrootdelegatesfocus"); +pub const shadowrootmode: HtmlAttr = HtmlAttr::constant("shadowrootmode"); +pub const shadowrootserializable: HtmlAttr = HtmlAttr::constant("shadowrootserializable"); +pub const shape: HtmlAttr = HtmlAttr::constant("shape"); +pub const size: HtmlAttr = HtmlAttr::constant("size"); +pub const sizes: HtmlAttr = HtmlAttr::constant("sizes"); +pub const slot: HtmlAttr = HtmlAttr::constant("slot"); +pub const span: HtmlAttr = HtmlAttr::constant("span"); +pub const spellcheck: HtmlAttr = HtmlAttr::constant("spellcheck"); +pub const src: HtmlAttr = HtmlAttr::constant("src"); +pub const srcdoc: HtmlAttr = HtmlAttr::constant("srcdoc"); +pub const srclang: HtmlAttr = HtmlAttr::constant("srclang"); +pub const srcset: HtmlAttr = HtmlAttr::constant("srcset"); +pub const start: HtmlAttr = HtmlAttr::constant("start"); +pub const step: HtmlAttr = HtmlAttr::constant("step"); +pub const style: HtmlAttr = HtmlAttr::constant("style"); +pub const tabindex: HtmlAttr = HtmlAttr::constant("tabindex"); +pub const target: HtmlAttr = HtmlAttr::constant("target"); +pub const title: HtmlAttr = HtmlAttr::constant("title"); +pub const translate: HtmlAttr = HtmlAttr::constant("translate"); +pub const r#type: HtmlAttr = HtmlAttr::constant("type"); +pub const usemap: HtmlAttr = HtmlAttr::constant("usemap"); +pub const value: HtmlAttr = HtmlAttr::constant("value"); +pub const width: HtmlAttr = HtmlAttr::constant("width"); +pub const wrap: HtmlAttr = HtmlAttr::constant("wrap"); +pub const writingsuggestions: HtmlAttr = HtmlAttr::constant("writingsuggestions"); diff --git a/crates/typst-html/src/charsets.rs b/crates/typst-html/src/charsets.rs new file mode 100644 index 000000000..251ff15c9 --- /dev/null +++ b/crates/typst-html/src/charsets.rs @@ -0,0 +1,81 @@ +//! Defines syntactical properties of HTML tags, attributes, and text. + +/// Check whether a character is in a tag name. +pub const fn is_valid_in_tag_name(c: char) -> bool { + c.is_ascii_alphanumeric() +} + +/// Check whether a character is valid in an attribute name. +pub const fn is_valid_in_attribute_name(c: char) -> bool { + match c { + // These are forbidden. + '\0' | ' ' | '"' | '\'' | '>' | '/' | '=' => false, + c if is_whatwg_control_char(c) => false, + c if is_whatwg_non_char(c) => false, + // _Everything_ else is allowed, including U+2029 paragraph + // separator. Go wild. + _ => true, + } +} + +/// Check whether a character can be an used in an attribute value without +/// escaping. +/// +/// See +pub const fn is_valid_in_attribute_value(c: char) -> bool { + match c { + // Ampersands are sometimes legal (i.e. when they are not _ambiguous + // ampersands_) but it is not worth the trouble to check for that. + '&' => false, + // Quotation marks are not allowed in double-quote-delimited attribute + // values. + '"' => false, + // All other text characters are allowed. + c => is_w3c_text_char(c), + } +} + +/// Check whether a character can be an used in normal text without +/// escaping. +pub const fn is_valid_in_normal_element_text(c: char) -> bool { + match c { + // Ampersands are sometimes legal (i.e. when they are not _ambiguous + // ampersands_) but it is not worth the trouble to check for that. + '&' => false, + // Less-than signs are not allowed in text. + '<' => false, + // All other text characters are allowed. + c => is_w3c_text_char(c), + } +} + +/// Check if something is valid text in HTML. +pub const fn is_w3c_text_char(c: char) -> bool { + match c { + // Non-characters are obviously not text characters. + c if is_whatwg_non_char(c) => false, + // Control characters are disallowed, except for whitespace. + c if is_whatwg_control_char(c) => c.is_ascii_whitespace(), + // Everything else is allowed. + _ => true, + } +} + +const fn is_whatwg_non_char(c: char) -> bool { + match c { + '\u{fdd0}'..='\u{fdef}' => true, + // Non-characters matching xxFFFE or xxFFFF up to x10FFFF (inclusive). + c if c as u32 & 0xfffe == 0xfffe && c as u32 <= 0x10ffff => true, + _ => false, + } +} + +const fn is_whatwg_control_char(c: char) -> bool { + match c { + // C0 control characters. + '\u{00}'..='\u{1f}' => true, + // Other control characters. + '\u{7f}'..='\u{9f}' => true, + _ => false, + } +} diff --git a/crates/typst-html/src/convert.rs b/crates/typst-html/src/convert.rs new file mode 100644 index 000000000..09d89313c --- /dev/null +++ b/crates/typst-html/src/convert.rs @@ -0,0 +1,127 @@ +use typst_library::diag::{SourceResult, warning}; +use typst_library::engine::Engine; +use typst_library::foundations::{Content, StyleChain, Target, TargetElem}; +use typst_library::introspection::{SplitLocator, TagElem}; +use typst_library::layout::{Abs, Axes, BlockBody, BlockElem, BoxElem, Region, Size}; +use typst_library::model::ParElem; +use typst_library::routines::Pair; +use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; + +use crate::fragment::html_fragment; +use crate::{FrameElem, HtmlElem, HtmlElement, HtmlFrame, HtmlNode, attr, tag}; + +/// Converts realized content into HTML nodes. +pub fn convert_to_nodes<'a>( + engine: &mut Engine, + locator: &mut SplitLocator, + children: impl IntoIterator>, +) -> SourceResult> { + let mut output = Vec::new(); + for (child, styles) in children { + handle(engine, child, locator, styles, &mut output)?; + } + Ok(output) +} + +/// Convert one element into HTML node(s). +fn handle( + engine: &mut Engine, + child: &Content, + locator: &mut SplitLocator, + styles: StyleChain, + output: &mut Vec, +) -> SourceResult<()> { + if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::Tag(elem.tag.clone())); + } else if let Some(elem) = child.to_packed::() { + let mut children = vec![]; + if let Some(body) = elem.body.get_ref(styles) { + children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; + } + let element = HtmlElement { + tag: elem.tag, + attrs: elem.attrs.get_cloned(styles), + children, + span: elem.span(), + }; + output.push(element.into()); + } else if let Some(elem) = child.to_packed::() { + let children = + html_fragment(engine, &elem.body, locator.next(&elem.span()), styles)?; + output.push( + HtmlElement::new(tag::p) + .with_children(children) + .spanned(elem.span()) + .into(), + ); + } else if let Some(elem) = child.to_packed::() { + // TODO: This is rather incomplete. + if let Some(body) = elem.body.get_ref(styles) { + let children = + html_fragment(engine, body, locator.next(&elem.span()), styles)?; + output.push( + HtmlElement::new(tag::span) + .with_attr(attr::style, "display: inline-block;") + .with_children(children) + .spanned(elem.span()) + .into(), + ) + } + } else if let Some((elem, body)) = + child + .to_packed::() + .and_then(|elem| match elem.body.get_ref(styles) { + Some(BlockBody::Content(body)) => Some((elem, body)), + _ => None, + }) + { + // TODO: This is rather incomplete. + let children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; + output.push( + HtmlElement::new(tag::div) + .with_children(children) + .spanned(elem.span()) + .into(), + ); + } else if child.is::() { + output.push(HtmlNode::text(' ', child.span())); + } else if let Some(elem) = child.to_packed::() { + let text = if let Some(case) = styles.get(TextElem::case) { + case.apply(&elem.text).into() + } else { + elem.text.clone() + }; + output.push(HtmlNode::text(text, elem.span())); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlElement::new(tag::br).spanned(elem.span()).into()); + } else if let Some(elem) = child.to_packed::() { + output.push(HtmlNode::text( + if elem.double.get(styles) { '"' } else { '\'' }, + child.span(), + )); + } else if let Some(elem) = child.to_packed::() { + let locator = locator.next(&elem.span()); + let style = TargetElem::target.set(Target::Paged).wrap(); + let frame = (engine.routines.layout_frame)( + engine, + &elem.body, + locator, + styles.chain(&style), + Region::new(Size::splat(Abs::inf()), Axes::splat(false)), + )?; + output.push(HtmlNode::Frame(HtmlFrame::new(frame, styles))); + } else { + engine.sink.warn(warning!( + child.span(), + "{} was ignored during HTML export", + child.elem().name() + )); + } + Ok(()) +} + +/// Checks whether the given element is an inline-level HTML element. +pub fn is_inline(elem: &Content) -> bool { + elem.to_packed::() + .is_some_and(|elem| tag::is_inline_by_default(elem.tag)) +} diff --git a/crates/typst-html/src/css.rs b/crates/typst-html/src/css.rs new file mode 100644 index 000000000..5916d3147 --- /dev/null +++ b/crates/typst-html/src/css.rs @@ -0,0 +1,178 @@ +//! Conversion from Typst data types into CSS data types. + +use std::fmt::{self, Display, Write}; + +use ecow::EcoString; +use typst_library::layout::{Length, Rel}; +use typst_library::visualize::{Color, Hsl, LinearRgb, Oklab, Oklch, Rgb}; +use typst_utils::Numeric; + +/// A list of CSS properties with values. +#[derive(Debug, Default)] +pub struct Properties(EcoString); + +impl Properties { + /// Creates an empty list. + pub fn new() -> Self { + Self::default() + } + + /// Adds a new property to the list. + pub fn push(&mut self, property: &str, value: impl Display) { + if !self.0.is_empty() { + self.0.push_str("; "); + } + write!(&mut self.0, "{property}: {value}").unwrap(); + } + + /// Adds a new property in builder-style. + #[expect(unused)] + pub fn with(mut self, property: &str, value: impl Display) -> Self { + self.push(property, value); + self + } + + /// Turns this into a string suitable for use as an inline `style` + /// attribute. + pub fn into_inline_styles(self) -> Option { + (!self.0.is_empty()).then_some(self.0) + } +} + +pub fn rel(rel: Rel) -> impl Display { + typst_utils::display(move |f| match (rel.abs.is_zero(), rel.rel.is_zero()) { + (false, false) => { + write!(f, "calc({}% + {})", rel.rel.get(), length(rel.abs)) + } + (true, false) => write!(f, "{}%", rel.rel.get()), + (_, true) => write!(f, "{}", length(rel.abs)), + }) +} + +pub fn length(length: Length) -> impl Display { + typst_utils::display(move |f| match (length.abs.is_zero(), length.em.is_zero()) { + (false, false) => { + write!(f, "calc({}pt + {}em)", length.abs.to_pt(), length.em.get()) + } + (true, false) => write!(f, "{}em", length.em.get()), + (_, true) => write!(f, "{}pt", length.abs.to_pt()), + }) +} + +pub fn color(color: Color) -> impl Display { + typst_utils::display(move |f| match color { + Color::Rgb(_) | Color::Cmyk(_) | Color::Luma(_) => rgb(f, color.to_rgb()), + Color::Oklab(v) => oklab(f, v), + Color::Oklch(v) => oklch(f, v), + Color::LinearRgb(v) => linear_rgb(f, v), + Color::Hsl(_) | Color::Hsv(_) => hsl(f, color.to_hsl()), + }) +} + +fn oklab(f: &mut fmt::Formatter<'_>, v: Oklab) -> fmt::Result { + write!(f, "oklab({} {} {}{})", percent(v.l), number(v.a), number(v.b), alpha(v.alpha)) +} + +fn oklch(f: &mut fmt::Formatter<'_>, v: Oklch) -> fmt::Result { + write!( + f, + "oklch({} {} {}deg{})", + percent(v.l), + number(v.chroma), + number(v.hue.into_degrees()), + alpha(v.alpha) + ) +} + +fn rgb(f: &mut fmt::Formatter<'_>, v: Rgb) -> fmt::Result { + if let Some(v) = rgb_to_8_bit_lossless(v) { + let (r, g, b, a) = v.into_components(); + write!(f, "#{r:02x}{g:02x}{b:02x}")?; + if a != u8::MAX { + write!(f, "{a:02x}")?; + } + Ok(()) + } else { + write!( + f, + "rgb({} {} {}{})", + percent(v.red), + percent(v.green), + percent(v.blue), + alpha(v.alpha) + ) + } +} + +/// Converts an f32 RGBA color to its 8-bit representation if the result is +/// [very close](is_very_close) to the original. +fn rgb_to_8_bit_lossless( + v: Rgb, +) -> Option> { + let l = v.into_format::(); + let h = l.into_format::(); + (is_very_close(v.red, h.red) + && is_very_close(v.blue, h.blue) + && is_very_close(v.green, h.green) + && is_very_close(v.alpha, h.alpha)) + .then_some(l) +} + +fn linear_rgb(f: &mut fmt::Formatter<'_>, v: LinearRgb) -> fmt::Result { + write!( + f, + "color(srgb-linear {} {} {}{})", + percent(v.red), + percent(v.green), + percent(v.blue), + alpha(v.alpha), + ) +} + +fn hsl(f: &mut fmt::Formatter<'_>, v: Hsl) -> fmt::Result { + write!( + f, + "hsl({}deg {} {}{})", + number(v.hue.into_degrees()), + percent(v.saturation), + percent(v.lightness), + alpha(v.alpha), + ) +} + +/// Displays an alpha component if it not 1. +fn alpha(value: f32) -> impl Display { + typst_utils::display(move |f| { + if !is_very_close(value, 1.0) { + write!(f, " / {}", percent(value))?; + } + Ok(()) + }) +} + +/// Displays a rounded percentage. +/// +/// For a percentage, two significant digits after the comma gives us a +/// precision of 1/10_000, which is more than 12 bits (see `is_very_close`). +fn percent(ratio: f32) -> impl Display { + typst_utils::display(move |f| { + write!(f, "{}%", typst_utils::round_with_precision(ratio as f64 * 100.0, 2)) + }) +} + +/// Rounds a number for display. +/// +/// For a number between 0 and 1, four significant digits give us a +/// precision of 1/10_000, which is more than 12 bits (see `is_very_close`). +fn number(value: f32) -> impl Display { + typst_utils::round_with_precision(value as f64, 4) +} + +/// Whether two component values are close enough that there is no +/// difference when encoding them with 12-bit. 12 bit is the highest +/// reasonable color bit depth found in the industry. +fn is_very_close(a: f32, b: f32) -> bool { + const MAX_BIT_DEPTH: u32 = 12; + const EPS: f32 = 0.5 / 2_i32.pow(MAX_BIT_DEPTH) as f32; + (a - b).abs() < EPS +} diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs new file mode 100644 index 000000000..ca84d3354 --- /dev/null +++ b/crates/typst-html/src/document.rs @@ -0,0 +1,236 @@ +use std::collections::HashSet; +use std::num::NonZeroUsize; + +use comemo::{Tracked, TrackedMut}; +use typst_library::World; +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::foundations::{Content, StyleChain}; +use typst_library::introspection::{ + Introspector, IntrospectorBuilder, Location, Locator, +}; +use typst_library::layout::{Point, Position, Transform}; +use typst_library::model::DocumentInfo; +use typst_library::routines::{Arenas, RealizationKind, Routines}; +use typst_syntax::Span; +use typst_utils::NonZeroExt; + +use crate::{HtmlDocument, HtmlElement, HtmlNode, attr, tag}; + +/// Produce an HTML document from content. +/// +/// This first performs root-level realization and then turns the resulting +/// elements into HTML. +#[typst_macros::time(name = "html document")] +pub fn html_document( + engine: &mut Engine, + content: &Content, + styles: StyleChain, +) -> SourceResult { + html_document_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + styles, + ) +} + +/// The internal implementation of `html_document`. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn html_document_impl( + routines: &Routines, + world: Tracked, + introspector: Tracked, + traced: Tracked, + sink: TrackedMut, + route: Tracked, + content: &Content, + styles: StyleChain, +) -> SourceResult { + let mut locator = Locator::root().split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route).unnested(), + }; + + // Mark the external styles as "outside" so that they are valid at the page + // level. + let styles = styles.to_map().outside(); + let styles = StyleChain::new(&styles); + + let arenas = Arenas::default(); + let mut info = DocumentInfo::default(); + let children = (engine.routines.realize)( + RealizationKind::HtmlDocument { + info: &mut info, + is_inline: crate::convert::is_inline, + }, + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + let output = crate::convert::convert_to_nodes( + &mut engine, + &mut locator, + children.iter().copied(), + )?; + + let mut link_targets = HashSet::new(); + let mut introspector = introspect_html(&output, &mut link_targets); + let mut root = root_element(output, &info)?; + crate::link::identify_link_targets(&mut root, &mut introspector, link_targets); + + Ok(HtmlDocument { info, root, introspector }) +} + +/// Introspects HTML nodes. +#[typst_macros::time(name = "introspect html")] +fn introspect_html( + output: &[HtmlNode], + link_targets: &mut HashSet, +) -> Introspector { + fn discover( + builder: &mut IntrospectorBuilder, + sink: &mut Vec<(Content, Position)>, + link_targets: &mut HashSet, + nodes: &[HtmlNode], + ) { + for node in nodes { + match node { + HtmlNode::Tag(tag) => { + builder.discover_in_tag( + sink, + tag, + Position { page: NonZeroUsize::ONE, point: Point::zero() }, + ); + } + HtmlNode::Text(_, _) => {} + HtmlNode::Element(elem) => { + discover(builder, sink, link_targets, &elem.children) + } + HtmlNode::Frame(frame) => { + builder.discover_in_frame( + sink, + &frame.inner, + NonZeroUsize::ONE, + Transform::identity(), + ); + crate::link::introspect_frame_links(&frame.inner, link_targets); + } + } + } + } + + let mut elems = Vec::new(); + let mut builder = IntrospectorBuilder::new(); + discover(&mut builder, &mut elems, link_targets, output); + builder.finalize(elems) +} + +/// Wrap the nodes in `` and `` if they are not yet rooted, +/// supplying a suitable ``. +fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { + let head = head_element(info); + let body = match classify_output(output)? { + OutputKind::Html(element) => return Ok(element), + OutputKind::Body(body) => body, + OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs), + }; + Ok(HtmlElement::new(tag::html).with_children(vec![head.into(), body.into()])) +} + +/// Generate a `` element. +fn head_element(info: &DocumentInfo) -> HtmlElement { + let mut children = vec![]; + + children.push(HtmlElement::new(tag::meta).with_attr(attr::charset, "utf-8").into()); + + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "viewport") + .with_attr(attr::content, "width=device-width, initial-scale=1") + .into(), + ); + + if let Some(title) = &info.title { + children.push( + HtmlElement::new(tag::title) + .with_children(vec![HtmlNode::Text(title.clone(), Span::detached())]) + .into(), + ); + } + + if let Some(description) = &info.description { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "description") + .with_attr(attr::content, description.clone()) + .into(), + ); + } + + if !info.author.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "authors") + .with_attr(attr::content, info.author.join(", ")) + .into(), + ) + } + + if !info.keywords.is_empty() { + children.push( + HtmlElement::new(tag::meta) + .with_attr(attr::name, "keywords") + .with_attr(attr::content, info.keywords.join(", ")) + .into(), + ) + } + + HtmlElement::new(tag::head).with_children(children) +} + +/// Determine which kind of output the user generated. +fn classify_output(mut output: Vec) -> SourceResult { + let count = output.iter().filter(|node| !matches!(node, HtmlNode::Tag(_))).count(); + for node in &mut output { + let HtmlNode::Element(elem) = node else { continue }; + let tag = elem.tag; + let mut take = || std::mem::replace(elem, HtmlElement::new(tag::html)); + match (tag, count) { + (tag::html, 1) => return Ok(OutputKind::Html(take())), + (tag::body, 1) => return Ok(OutputKind::Body(take())), + (tag::html | tag::body, _) => bail!( + elem.span, + "`{}` element must be the only element in the document", + elem.tag, + ), + _ => {} + } + } + Ok(OutputKind::Leafs(output)) +} + +/// What kinds of output the user generated. +enum OutputKind { + /// The user generated their own `` element. We do not need to supply + /// one. + Html(HtmlElement), + /// The user generate their own `` element. We do not need to supply + /// one, but need supply the `` element. + Body(HtmlElement), + /// The user generated leafs which we wrap in a `` and ``. + Leafs(Vec), +} diff --git a/crates/typst-html/src/dom.rs b/crates/typst-html/src/dom.rs new file mode 100644 index 000000000..2c9a2e640 --- /dev/null +++ b/crates/typst-html/src/dom.rs @@ -0,0 +1,308 @@ +use std::fmt::{self, Debug, Display, Formatter}; + +use ecow::{EcoString, EcoVec}; +use typst_library::diag::{HintedStrResult, StrResult, bail}; +use typst_library::foundations::{Dict, Repr, Str, StyleChain, cast}; +use typst_library::introspection::{Introspector, Tag}; +use typst_library::layout::{Abs, Frame, Point}; +use typst_library::model::DocumentInfo; +use typst_library::text::TextElem; +use typst_syntax::Span; +use typst_utils::{PicoStr, ResolvedPicoStr}; + +use crate::charsets; + +/// An HTML document. +#[derive(Debug, Clone)] +pub struct HtmlDocument { + /// The document's root HTML element. + pub root: HtmlElement, + /// Details about the document. + pub info: DocumentInfo, + /// Provides the ability to execute queries on the document. + pub introspector: Introspector, +} + +/// A child of an HTML element. +#[derive(Debug, Clone, Hash)] +pub enum HtmlNode { + /// An introspectable element that produced something within this node. + Tag(Tag), + /// Plain text. + Text(EcoString, Span), + /// Another element. + Element(HtmlElement), + /// Layouted content that will be embedded into HTML as an SVG. + Frame(HtmlFrame), +} + +impl HtmlNode { + /// Create a plain text node. + pub fn text(text: impl Into, span: Span) -> Self { + Self::Text(text.into(), span) + } +} + +impl From for HtmlNode { + fn from(element: HtmlElement) -> Self { + Self::Element(element) + } +} + +/// An HTML element. +#[derive(Debug, Clone, Hash)] +pub struct HtmlElement { + /// The HTML tag. + pub tag: HtmlTag, + /// The element's attributes. + pub attrs: HtmlAttrs, + /// The element's children. + pub children: Vec, + /// The span from which the element originated, if any. + pub span: Span, +} + +impl HtmlElement { + /// Create a new, blank element without attributes or children. + pub fn new(tag: HtmlTag) -> Self { + Self { + tag, + attrs: HtmlAttrs::default(), + children: vec![], + span: Span::detached(), + } + } + + /// Attach children to the element. + /// + /// Note: This overwrites potential previous children. + pub fn with_children(mut self, children: Vec) -> Self { + self.children = children; + self + } + + /// Add an atribute to the element. + pub fn with_attr(mut self, key: HtmlAttr, value: impl Into) -> Self { + self.attrs.push(key, value); + self + } + + /// Attach a span to the element. + pub fn spanned(mut self, span: Span) -> Self { + self.span = span; + self + } +} + +/// The tag of an HTML element. +#[derive(Copy, Clone, Eq, PartialEq, Hash)] +pub struct HtmlTag(PicoStr); + +impl HtmlTag { + /// Intern an HTML tag string at runtime. + pub fn intern(string: &str) -> StrResult { + if string.is_empty() { + bail!("tag name must not be empty"); + } + + if let Some(c) = string.chars().find(|&c| !charsets::is_valid_in_tag_name(c)) { + bail!("the character {} is not valid in a tag name", c.repr()); + } + + Ok(Self(PicoStr::intern(string))) + } + + /// Creates a compile-time constant `HtmlTag`. + /// + /// Should only be used in const contexts because it can panic. + #[track_caller] + pub const fn constant(string: &'static str) -> Self { + if string.is_empty() { + panic!("tag name must not be empty"); + } + + let bytes = string.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if !bytes[i].is_ascii() || !charsets::is_valid_in_tag_name(bytes[i] as char) { + panic!("not all characters are valid in a tag name"); + } + i += 1; + } + + Self(PicoStr::constant(string)) + } + + /// Resolves the tag to a string. + pub fn resolve(self) -> ResolvedPicoStr { + self.0.resolve() + } + + /// Turns the tag into its inner interned string. + pub const fn into_inner(self) -> PicoStr { + self.0 + } +} + +impl Debug for HtmlTag { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self, f) + } +} + +impl Display for HtmlTag { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "<{}>", self.resolve()) + } +} + +cast! { + HtmlTag, + self => self.0.resolve().as_str().into_value(), + v: Str => Self::intern(&v)?, +} + +/// Attributes of an HTML element. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct HtmlAttrs(pub EcoVec<(HtmlAttr, EcoString)>); + +impl HtmlAttrs { + /// Creates an empty attribute list. + pub fn new() -> Self { + Self::default() + } + + /// Adds an attribute. + pub fn push(&mut self, attr: HtmlAttr, value: impl Into) { + self.0.push((attr, value.into())); + } + + /// Adds an attribute to the start of the list. + pub fn push_front(&mut self, attr: HtmlAttr, value: impl Into) { + self.0.insert(0, (attr, value.into())); + } + + /// Finds an attribute value. + pub fn get(&self, attr: HtmlAttr) -> Option<&EcoString> { + self.0.iter().find(|&&(k, _)| k == attr).map(|(_, v)| v) + } +} + +cast! { + HtmlAttrs, + self => self.0 + .into_iter() + .map(|(key, value)| (key.resolve().as_str().into(), value.into_value())) + .collect::() + .into_value(), + values: Dict => Self(values + .into_iter() + .map(|(k, v)| { + let attr = HtmlAttr::intern(&k)?; + let value = v.cast::()?; + Ok((attr, value)) + }) + .collect::>()?), +} + +/// An attribute of an HTML element. +#[derive(Copy, Clone, Eq, PartialEq, Hash)] +pub struct HtmlAttr(PicoStr); + +impl HtmlAttr { + /// Intern an HTML attribute string at runtime. + pub fn intern(string: &str) -> StrResult { + if string.is_empty() { + bail!("attribute name must not be empty"); + } + + if let Some(c) = + string.chars().find(|&c| !charsets::is_valid_in_attribute_name(c)) + { + bail!("the character {} is not valid in an attribute name", c.repr()); + } + + Ok(Self(PicoStr::intern(string))) + } + + /// Creates a compile-time constant `HtmlAttr`. + /// + /// Must only be used in const contexts (in a constant definition or + /// explicit `const { .. }` block) because otherwise a panic for a malformed + /// attribute or not auto-internible constant will only be caught at + /// runtime. + #[track_caller] + pub const fn constant(string: &'static str) -> Self { + if string.is_empty() { + panic!("attribute name must not be empty"); + } + + let bytes = string.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if !bytes[i].is_ascii() + || !charsets::is_valid_in_attribute_name(bytes[i] as char) + { + panic!("not all characters are valid in an attribute name"); + } + i += 1; + } + + Self(PicoStr::constant(string)) + } + + /// Resolves the attribute to a string. + pub fn resolve(self) -> ResolvedPicoStr { + self.0.resolve() + } + + /// Turns the attribute into its inner interned string. + pub const fn into_inner(self) -> PicoStr { + self.0 + } +} + +impl Debug for HtmlAttr { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self, f) + } +} + +impl Display for HtmlAttr { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.resolve()) + } +} + +cast! { + HtmlAttr, + self => self.0.resolve().as_str().into_value(), + v: Str => Self::intern(&v)?, +} + +/// Layouted content that will be embedded into HTML as an SVG. +#[derive(Debug, Clone, Hash)] +pub struct HtmlFrame { + /// The frame that will be displayed as an SVG. + pub inner: Frame, + /// The text size where the frame was defined. This is used to size the + /// frame with em units to make text in and outside of the frame sized + /// consistently. + pub text_size: Abs, + /// An ID to assign to the SVG itself. + pub id: Option, + /// IDs to assign to destination jump points within the SVG. + pub link_points: Vec<(Point, EcoString)>, +} + +impl HtmlFrame { + /// Wraps a laid-out frame. + pub fn new(inner: Frame, styles: StyleChain) -> Self { + Self { + inner, + text_size: styles.resolve(TextElem::size), + id: None, + link_points: vec![], + } + } +} diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 612f923fc..2d21109c1 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -1,14 +1,17 @@ use std::fmt::Write; -use typst_library::diag::{bail, At, SourceResult, StrResult}; +use typst_library::diag::{At, SourceResult, StrResult, bail}; use typst_library::foundations::Repr; -use typst_library::html::{charsets, tag, HtmlDocument, HtmlElement, HtmlNode, HtmlTag}; -use typst_library::layout::Frame; +use typst_library::introspection::Introspector; use typst_syntax::Span; +use crate::{ + HtmlDocument, HtmlElement, HtmlFrame, HtmlNode, HtmlTag, attr, charsets, tag, +}; + /// Encodes an HTML document into a string. pub fn html(document: &HtmlDocument) -> SourceResult { - let mut w = Writer { pretty: true, ..Writer::default() }; + let mut w = Writer::new(&document.introspector, true); w.buf.push_str(""); write_indent(&mut w); write_element(&mut w, &document.root)?; @@ -18,17 +21,26 @@ pub fn html(document: &HtmlDocument) -> SourceResult { Ok(w.buf) } -#[derive(Default)] -struct Writer { +/// Encodes HTML. +struct Writer<'a> { /// The output buffer. buf: String, /// The current indentation level level: usize, + /// The document's introspector. + introspector: &'a Introspector, /// Whether pretty printing is enabled. pretty: bool, } -/// Write a newline and indent, if pretty printing is enabled. +impl<'a> Writer<'a> { + /// Creates a new writer. + fn new(introspector: &'a Introspector, pretty: bool) -> Self { + Self { buf: String::new(), level: 0, introspector, pretty } + } +} + +/// Writes a newline and indent, if pretty printing is enabled. fn write_indent(w: &mut Writer) { if w.pretty { w.buf.push('\n'); @@ -38,7 +50,7 @@ fn write_indent(w: &mut Writer) { } } -/// Encode an HTML node into the writer. +/// Encodes an HTML node into the writer. fn write_node(w: &mut Writer, node: &HtmlNode) -> SourceResult<()> { match node { HtmlNode::Tag(_) => {} @@ -49,7 +61,7 @@ fn write_node(w: &mut Writer, node: &HtmlNode) -> SourceResult<()> { Ok(()) } -/// Encode plain text into the writer. +/// Encodes plain text into the writer. fn write_text(w: &mut Writer, text: &str, span: Span) -> SourceResult<()> { for c in text.chars() { if charsets::is_valid_in_normal_element_text(c) { @@ -61,7 +73,7 @@ fn write_text(w: &mut Writer, text: &str, span: Span) -> SourceResult<()> { Ok(()) } -/// Encode one element into the write. +/// Encodes one element into the writer. fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { w.buf.push('<'); w.buf.push_str(&element.tag.resolve()); @@ -69,54 +81,37 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { for (attr, value) in &element.attrs.0 { w.buf.push(' '); w.buf.push_str(&attr.resolve()); - w.buf.push('='); - w.buf.push('"'); - for c in value.chars() { - if charsets::is_valid_in_attribute_value(c) { - w.buf.push(c); - } else { - write_escape(w, c).at(element.span)?; + + // If the string is empty, we can use shorthand syntax. + // `....` + if !value.is_empty() { + w.buf.push('='); + w.buf.push('"'); + for c in value.chars() { + if charsets::is_valid_in_attribute_value(c) { + w.buf.push(c); + } else { + write_escape(w, c).at(element.span)?; + } } + w.buf.push('"'); } - w.buf.push('"'); } w.buf.push('>'); if tag::is_void(element.tag) { + if !element.children.is_empty() { + bail!(element.span, "HTML void elements must not have children"); + } return Ok(()); } - let pretty = w.pretty; - if !element.children.is_empty() { - let pretty_inside = allows_pretty_inside(element.tag) - && element.children.iter().any(|node| match node { - HtmlNode::Element(child) => wants_pretty_around(child.tag), - _ => false, - }); - - w.pretty &= pretty_inside; - let mut indent = w.pretty; - - w.level += 1; - for c in &element.children { - let pretty_around = match c { - HtmlNode::Tag(_) => continue, - HtmlNode::Element(child) => w.pretty && wants_pretty_around(child.tag), - HtmlNode::Text(..) | HtmlNode::Frame(_) => false, - }; - - if core::mem::take(&mut indent) || pretty_around { - write_indent(w); - } - write_node(w, c)?; - indent = pretty_around; - } - w.level -= 1; - - write_indent(w); + if tag::is_raw(element.tag) { + write_raw(w, element)?; + } else if !element.children.is_empty() { + write_children(w, element)?; } - w.pretty = pretty; w.buf.push_str(" SourceResult<()> { Ok(()) } +/// Encodes the children of an element. +fn write_children(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { + // See HTML spec § 13.1.2.5. + if matches!(element.tag, tag::pre | tag::textarea) && starts_with_newline(element) { + w.buf.push('\n'); + } + + let pretty = w.pretty; + let pretty_inside = allows_pretty_inside(element.tag) + && element.children.iter().any(|node| match node { + HtmlNode::Element(child) => wants_pretty_around(child.tag), + HtmlNode::Frame(_) => true, + _ => false, + }); + + w.pretty &= pretty_inside; + let mut indent = w.pretty; + + w.level += 1; + for c in &element.children { + let pretty_around = match c { + HtmlNode::Tag(_) => continue, + HtmlNode::Element(child) => w.pretty && wants_pretty_around(child.tag), + HtmlNode::Text(..) | HtmlNode::Frame(_) => false, + }; + + if core::mem::take(&mut indent) || pretty_around { + write_indent(w); + } + write_node(w, c)?; + indent = pretty_around; + } + w.level -= 1; + + write_indent(w); + w.pretty = pretty; + + Ok(()) +} + +/// Whether the first character in the element is a newline. +fn starts_with_newline(element: &HtmlElement) -> bool { + for child in &element.children { + match child { + HtmlNode::Tag(_) => {} + HtmlNode::Text(text, _) => return text.starts_with(['\n', '\r']), + _ => return false, + } + } + false +} + +/// Encodes the contents of a raw text element. +fn write_raw(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { + let text = collect_raw_text(element)?; + + if let Some(closing) = find_closing_tag(&text, element.tag) { + bail!( + element.span, + "HTML raw text element cannot contain its own closing tag"; + hint: "the sequence `{closing}` appears in the raw text", + ) + } + + let mode = if w.pretty { RawMode::of(element, &text) } else { RawMode::Keep }; + match mode { + RawMode::Keep => { + w.buf.push_str(&text); + } + RawMode::Wrap => { + w.buf.push('\n'); + w.buf.push_str(&text); + write_indent(w); + } + RawMode::Indent => { + w.level += 1; + for line in text.lines() { + write_indent(w); + w.buf.push_str(line); + } + w.level -= 1; + write_indent(w); + } + } + + Ok(()) +} + +/// Collects the textual contents of a raw text element. +fn collect_raw_text(element: &HtmlElement) -> SourceResult { + let mut output = String::new(); + for c in &element.children { + match c { + HtmlNode::Tag(_) => continue, + HtmlNode::Text(text, _) => output.push_str(text), + HtmlNode::Element(_) | HtmlNode::Frame(_) => { + let span = match c { + HtmlNode::Element(child) => child.span, + _ => element.span, + }; + bail!(span, "HTML raw text element cannot have non-text children") + } + }; + } + Ok(output) +} + +/// Finds a closing sequence for the given tag in the text, if it exists. +/// +/// See HTML spec § 13.1.2.6. +fn find_closing_tag(text: &str, tag: HtmlTag) -> Option<&str> { + let s = tag.resolve(); + let len = s.len(); + text.match_indices("= len + && rest[..len].eq_ignore_ascii_case(&s) + && rest[len..].starts_with(['\t', '\n', '\u{c}', '\r', ' ', '>', '/']); + disallowed.then(|| &text[i..i + 2 + len]) + }) +} + +/// How to format the contents of a raw text element. +enum RawMode { + /// Just don't touch it. + Keep, + /// Newline after the opening and newline + indent before the closing tag. + Wrap, + /// Newlines after opening and before closing tag and each line indented. + Indent, +} + +impl RawMode { + fn of(element: &HtmlElement, text: &str) -> Self { + match element.tag { + tag::script + if !element.attrs.0.iter().any(|(attr, value)| { + *attr == attr::r#type && value != "text/javascript" + }) => + { + // Template literals can be multi-line, so indent may change + // the semantics of the JavaScript. + if text.contains('`') { Self::Wrap } else { Self::Indent } + } + tag::style => Self::Indent, + _ => Self::Keep, + } + } +} + /// Whether we are allowed to add an extra newline at the start and end of the /// element's contents. /// @@ -160,15 +305,19 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> { c if charsets::is_w3c_text_char(c) && c != '\r' => { write!(w.buf, "&#x{:x};", c as u32).unwrap() } - _ => bail!("the character {} cannot be encoded in HTML", c.repr()), + _ => bail!("the character `{}` cannot be encoded in HTML", c.repr()), } Ok(()) } /// Encode a laid out frame into the writer. -fn write_frame(w: &mut Writer, frame: &Frame) { - // FIXME: This string replacement is obviously a hack. - let svg = typst_svg::svg_frame(frame) - .replace(" SourceResult> { + html_fragment_impl( + engine.routines, + engine.world, + engine.introspector, + engine.traced, + TrackedMut::reborrow_mut(&mut engine.sink), + engine.route.track(), + content, + locator.track(), + styles, + ) +} + +/// The cached, internal implementation of [`html_fragment`]. +#[comemo::memoize] +#[allow(clippy::too_many_arguments)] +fn html_fragment_impl( + routines: &Routines, + world: Tracked, + introspector: Tracked, + traced: Tracked, + sink: TrackedMut, + route: Tracked, + content: &Content, + locator: Tracked, + styles: StyleChain, +) -> SourceResult> { + let link = LocatorLink::new(locator); + let mut locator = Locator::link(&link).split(); + let mut engine = Engine { + routines, + world, + introspector, + traced, + sink, + route: Route::extend(route), + }; + + engine.route.check_html_depth().at(content.span())?; + + let arenas = Arenas::default(); + let children = (engine.routines.realize)( + // No need to know about the `FragmentKind` because we handle both + // uniformly. + RealizationKind::HtmlFragment { + kind: &mut FragmentKind::Block, + is_inline: crate::convert::is_inline, + }, + &mut engine, + &mut locator, + &arenas, + content, + styles, + )?; + + crate::convert::convert_to_nodes(&mut engine, &mut locator, children.iter().copied()) +} diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index 7d78a5da4..373da607f 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -1,357 +1,118 @@ //! Typst's HTML exporter. +mod attr; +mod charsets; +mod convert; +mod css; +mod document; +mod dom; mod encode; +mod fragment; +mod link; +mod rules; +mod tag; +mod typed; +pub use self::document::html_document; +pub use self::dom::*; pub use self::encode::html; +pub use self::rules::register; -use comemo::{Track, Tracked, TrackedMut}; -use typst_library::diag::{bail, warning, At, SourceResult}; -use typst_library::engine::{Engine, Route, Sink, Traced}; -use typst_library::foundations::{Content, StyleChain, Target, TargetElem}; -use typst_library::html::{ - attr, tag, FrameElem, HtmlDocument, HtmlElem, HtmlElement, HtmlNode, -}; -use typst_library::introspection::{ - Introspector, Locator, LocatorLink, SplitLocator, TagElem, -}; -use typst_library::layout::{Abs, Axes, BlockBody, BlockElem, BoxElem, Region, Size}; -use typst_library::model::{DocumentInfo, ParElem}; -use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind, Routines}; -use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; -use typst_library::World; -use typst_syntax::Span; +use ecow::EcoString; +use typst_library::Category; +use typst_library::foundations::{Content, Module, Scope}; +use typst_macros::elem; -/// Produce an HTML document from content. +/// 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. /// -/// This first performs root-level realization and then turns the resulting -/// elements into HTML. -#[typst_macros::time(name = "html document")] -pub fn html_document( - engine: &mut Engine, - content: &Content, - styles: StyleChain, -) -> SourceResult { - html_document_impl( - engine.routines, - engine.world, - engine.introspector, - engine.traced, - TrackedMut::reborrow_mut(&mut engine.sink), - engine.route.track(), - content, - styles, - ) +/// 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. + pub attrs: HtmlAttrs, + + /// The contents of the HTML element. + /// + /// The body can be arbitrary Typst content. + #[positional] + pub body: Option, } -/// The internal implementation of `html_document`. -#[comemo::memoize] -#[allow(clippy::too_many_arguments)] -fn html_document_impl( - routines: &Routines, - world: Tracked, - introspector: Tracked, - traced: Tracked, - sink: TrackedMut, - route: Tracked, - content: &Content, - styles: StyleChain, -) -> SourceResult { - let mut locator = Locator::root().split(); - let mut engine = Engine { - routines, - world, - introspector, - traced, - sink, - route: Route::extend(route).unnested(), - }; - - // Mark the external styles as "outside" so that they are valid at the page - // level. - let styles = styles.to_map().outside(); - let styles = StyleChain::new(&styles); - - let arenas = Arenas::default(); - let mut info = DocumentInfo::default(); - let children = (engine.routines.realize)( - RealizationKind::HtmlDocument(&mut info), - &mut engine, - &mut locator, - &arenas, - content, - styles, - )?; - - let output = handle_list(&mut engine, &mut locator, children.iter().copied())?; - let introspector = Introspector::html(&output); - let root = root_element(output, &info)?; - - Ok(HtmlDocument { info, root, introspector }) -} - -/// Produce HTML nodes from content. -#[typst_macros::time(name = "html fragment")] -pub fn html_fragment( - engine: &mut Engine, - content: &Content, - locator: Locator, - styles: StyleChain, -) -> SourceResult> { - html_fragment_impl( - engine.routines, - engine.world, - engine.introspector, - engine.traced, - TrackedMut::reborrow_mut(&mut engine.sink), - engine.route.track(), - content, - locator.track(), - styles, - ) -} - -/// The cached, internal implementation of [`html_fragment`]. -#[comemo::memoize] -#[allow(clippy::too_many_arguments)] -fn html_fragment_impl( - routines: &Routines, - world: Tracked, - introspector: Tracked, - traced: Tracked, - sink: TrackedMut, - route: Tracked, - content: &Content, - locator: Tracked, - styles: StyleChain, -) -> SourceResult> { - let link = LocatorLink::new(locator); - let mut locator = Locator::link(&link).split(); - let mut engine = Engine { - routines, - world, - introspector, - traced, - sink, - route: Route::extend(route), - }; - - engine.route.check_html_depth().at(content.span())?; - - let arenas = Arenas::default(); - let children = (engine.routines.realize)( - // No need to know about the `FragmentKind` because we handle both - // uniformly. - RealizationKind::HtmlFragment(&mut FragmentKind::Block), - &mut engine, - &mut locator, - &arenas, - content, - styles, - )?; - - handle_list(&mut engine, &mut locator, children.iter().copied()) -} - -/// Convert children into HTML nodes. -fn handle_list<'a>( - engine: &mut Engine, - locator: &mut SplitLocator, - children: impl IntoIterator>, -) -> SourceResult> { - let mut output = Vec::new(); - for (child, styles) in children { - handle(engine, child, locator, styles, &mut output)?; - } - Ok(output) -} - -/// Convert a child into HTML node(s). -fn handle( - engine: &mut Engine, - child: &Content, - locator: &mut SplitLocator, - styles: StyleChain, - output: &mut Vec, -) -> SourceResult<()> { - if let Some(elem) = child.to_packed::() { - output.push(HtmlNode::Tag(elem.tag.clone())); - } else if let Some(elem) = child.to_packed::() { - let mut children = vec![]; - if let Some(body) = elem.body(styles) { - children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; - } - if tag::is_void(elem.tag) && !children.is_empty() { - bail!(elem.span(), "HTML void elements may not have children"); - } - let element = HtmlElement { - tag: elem.tag, - attrs: elem.attrs(styles).clone(), - children, - span: elem.span(), - }; - output.push(element.into()); - } else if let Some(elem) = child.to_packed::() { - let children = - html_fragment(engine, &elem.body, locator.next(&elem.span()), styles)?; - output.push( - HtmlElement::new(tag::p) - .with_children(children) - .spanned(elem.span()) - .into(), - ); - } else if let Some(elem) = child.to_packed::() { - // TODO: This is rather incomplete. - if let Some(body) = elem.body(styles) { - let children = - html_fragment(engine, body, locator.next(&elem.span()), styles)?; - output.push( - HtmlElement::new(tag::span) - .with_attr(attr::style, "display: inline-block;") - .with_children(children) - .spanned(elem.span()) - .into(), - ) - } - } else if let Some((elem, body)) = - child - .to_packed::() - .and_then(|elem| match elem.body(styles) { - Some(BlockBody::Content(body)) => Some((elem, body)), - _ => None, - }) - { - // TODO: This is rather incomplete. - let children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; - output.push( - HtmlElement::new(tag::div) - .with_children(children) - .spanned(elem.span()) - .into(), - ); - } else if child.is::() { - output.push(HtmlNode::text(' ', child.span())); - } else if let Some(elem) = child.to_packed::() { - output.push(HtmlNode::text(elem.text.clone(), elem.span())); - } else if let Some(elem) = child.to_packed::() { - output.push(HtmlElement::new(tag::br).spanned(elem.span()).into()); - } else if let Some(elem) = child.to_packed::() { - output.push(HtmlNode::text( - if elem.double(styles) { '"' } else { '\'' }, - child.span(), - )); - } else if let Some(elem) = child.to_packed::() { - let locator = locator.next(&elem.span()); - let style = TargetElem::set_target(Target::Paged).wrap(); - let frame = (engine.routines.layout_frame)( - engine, - &elem.body, - locator, - styles.chain(&style), - Region::new(Size::splat(Abs::inf()), Axes::splat(false)), - )?; - output.push(HtmlNode::Frame(frame)); - } else { - engine.sink.warn(warning!( - child.span(), - "{} was ignored during HTML export", - child.elem().name() - )); - } - Ok(()) -} - -/// Wrap the nodes in `` and `
` if they are not yet rooted, -/// supplying a suitable ``. -fn root_element(output: Vec, info: &DocumentInfo) -> SourceResult { - let head = head_element(info); - let body = match classify_output(output)? { - OutputKind::Html(element) => return Ok(element), - OutputKind::Body(body) => body, - OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs), - }; - Ok(HtmlElement::new(tag::html).with_children(vec![head.into(), body.into()])) -} - -/// Generate a `` element. -fn head_element(info: &DocumentInfo) -> HtmlElement { - let mut children = vec![]; - - children.push(HtmlElement::new(tag::meta).with_attr(attr::charset, "utf-8").into()); - - children.push( - HtmlElement::new(tag::meta) - .with_attr(attr::name, "viewport") - .with_attr(attr::content, "width=device-width, initial-scale=1") - .into(), - ); - - if let Some(title) = &info.title { - children.push( - HtmlElement::new(tag::title) - .with_children(vec![HtmlNode::Text(title.clone(), Span::detached())]) - .into(), - ); +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 } - if let Some(description) = &info.description { - children.push( - HtmlElement::new(tag::meta) - .with_attr(attr::name, "description") - .with_attr(attr::content, description.clone()) - .into(), - ); + /// 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 } } - if !info.author.is_empty() { - children.push( - HtmlElement::new(tag::meta) - .with_attr(attr::name, "authors") - .with_attr(attr::content, info.author.join(", ")) - .into(), - ) - } - - if !info.keywords.is_empty() { - children.push( - HtmlElement::new(tag::meta) - .with_attr(attr::name, "keywords") - .with_attr(attr::content, info.keywords.join(", ")) - .into(), - ) - } - - HtmlElement::new(tag::head).with_children(children) -} - -/// Determine which kind of output the user generated. -fn classify_output(mut output: Vec) -> SourceResult { - let count = output.iter().filter(|node| !matches!(node, HtmlNode::Tag(_))).count(); - for node in &mut output { - let HtmlNode::Element(elem) = node else { continue }; - let tag = elem.tag; - let mut take = || std::mem::replace(elem, HtmlElement::new(tag::html)); - match (tag, count) { - (tag::html, 1) => return Ok(OutputKind::Html(take())), - (tag::body, 1) => return Ok(OutputKind::Body(take())), - (tag::html | tag::body, _) => bail!( - elem.span, - "`{}` element must be the only element in the document", - elem.tag, - ), - _ => {} + /// Adds CSS styles to an element. + fn with_styles(self, properties: css::Properties) -> Self { + if let Some(value) = properties.into_inline_styles() { + self.with_attr(attr::style, value) + } else { + self } } - Ok(OutputKind::Leafs(output)) } -/// What kinds of output the user generated. -enum OutputKind { - /// The user generated their own `` element. We do not need to supply - /// one. - Html(HtmlElement), - /// The user generate their own `` element. We do not need to supply - /// one, but need supply the `` element. - Body(HtmlElement), - /// The user generated leafs which we wrap in a `` and ``. - Leafs(Vec), +/// 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/link.rs b/crates/typst-html/src/link.rs new file mode 100644 index 000000000..ad70f06fe --- /dev/null +++ b/crates/typst-html/src/link.rs @@ -0,0 +1,290 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use comemo::Track; +use ecow::{EcoString, eco_format}; +use typst_library::foundations::{Label, NativeElement}; +use typst_library::introspection::{Introspector, Location, Tag}; +use typst_library::layout::{Frame, FrameItem, Point}; +use typst_library::model::{Destination, LinkElem}; +use typst_utils::PicoStr; + +use crate::{HtmlElement, HtmlNode, attr, tag}; + +/// Searches for links within a frame. +/// +/// If all links are created via `LinkElem` in the future, this can be removed +/// in favor of the query in `identify_link_targets`. For the time being, some +/// links are created without existence of a `LinkElem`, so this is +/// unfortunately necessary. +pub fn introspect_frame_links(frame: &Frame, targets: &mut HashSet) { + for (_, item) in frame.items() { + match item { + FrameItem::Link(Destination::Location(loc), _) => { + targets.insert(*loc); + } + FrameItem::Group(group) => introspect_frame_links(&group.frame, targets), + _ => {} + } + } +} + +/// Attaches IDs to nodes produced by link targets to make them linkable. +/// +/// May produce ``s for link targets that turned into text nodes or no +/// nodes at all. See the [`LinkElem`] documentation for more details. +pub fn identify_link_targets( + root: &mut HtmlElement, + introspector: &mut Introspector, + mut targets: HashSet, +) { + // Query for all links with an intra-doc (i.e. `Location`) destination to + // know what needs IDs. + targets.extend( + introspector + .query(&LinkElem::ELEM.select()) + .iter() + .map(|elem| elem.to_packed::().unwrap()) + .filter_map(|elem| match elem.dest.resolve(introspector.track()) { + Ok(Destination::Location(loc)) => Some(loc), + _ => None, + }), + ); + + if targets.is_empty() { + // Nothing to do. + return; + } + + // Assign IDs to all link targets. + let mut work = Work::new(); + traverse( + &mut work, + &targets, + &mut Identificator::new(introspector), + &mut root.children, + ); + + // Add the mapping from locations to IDs to the introspector to make it + // available to links in the next iteration. + introspector.set_html_ids(work.ids); +} + +/// Traverses a list of nodes. +fn traverse( + work: &mut Work, + targets: &HashSet, + identificator: &mut Identificator<'_>, + nodes: &mut Vec, +) { + let mut i = 0; + while i < nodes.len() { + let node = &mut nodes[i]; + match node { + // When visiting a start tag, we check whether the element needs an + // ID and if so, add it to the queue, so that its first child node + // receives an ID. + HtmlNode::Tag(Tag::Start(elem)) => { + let loc = elem.location().unwrap(); + if targets.contains(&loc) { + work.enqueue(loc, elem.label()); + } + } + + // When we reach an end tag, we check whether it closes an element + // that is still in our queue. If so, that means the element + // produced no nodes and we need to insert an empty span. + HtmlNode::Tag(Tag::End(loc, _)) => { + work.remove(*loc, |label| { + let mut element = HtmlElement::new(tag::span); + let id = identificator.assign(&mut element, label); + nodes.insert(i + 1, HtmlNode::Element(element)); + id + }); + } + + // When visiting an element and the queue is non-empty, we assign an + // ID. Then, we traverse its children. + HtmlNode::Element(element) => { + work.drain(|label| identificator.assign(element, label)); + traverse(work, targets, identificator, &mut element.children); + } + + // When visiting text and the queue is non-empty, we generate a span + // and assign an ID. + HtmlNode::Text(..) => { + work.drain(|label| { + let mut element = + HtmlElement::new(tag::span).with_children(vec![node.clone()]); + let id = identificator.assign(&mut element, label); + *node = HtmlNode::Element(element); + id + }); + } + + // When visiting a frame and the queue is non-empty, we assign an + // ID to it (will be added to the resulting SVG element). + HtmlNode::Frame(frame) => { + work.drain(|label| { + frame.id.get_or_insert_with(|| identificator.identify(label)).clone() + }); + traverse_frame( + work, + targets, + identificator, + &frame.inner, + &mut frame.link_points, + ); + } + } + + i += 1; + } +} + +/// Traverses a frame embedded in HTML. +fn traverse_frame( + work: &mut Work, + targets: &HashSet, + identificator: &mut Identificator<'_>, + frame: &Frame, + link_points: &mut Vec<(Point, EcoString)>, +) { + for (_, item) in frame.items() { + match item { + FrameItem::Tag(Tag::Start(elem)) => { + let loc = elem.location().unwrap(); + if targets.contains(&loc) { + let pos = identificator.introspector.position(loc).point; + let id = identificator.identify(elem.label()); + work.ids.insert(loc, id.clone()); + link_points.push((pos, id)); + } + } + FrameItem::Group(group) => { + traverse_frame(work, targets, identificator, &group.frame, link_points); + } + _ => {} + } + } +} + +/// Keeps track of the work to be done during ID generation. +struct Work { + /// The locations and labels of elements we need to assign an ID to right + /// now. + queue: VecDeque<(Location, Option