diff --git a/.github/workflows/actions/quarto-dev/action.yml b/.github/workflows/actions/quarto-dev/action.yml index 63690b7dc75..c77c782c70c 100644 --- a/.github/workflows/actions/quarto-dev/action.yml +++ b/.github/workflows/actions/quarto-dev/action.yml @@ -12,6 +12,22 @@ runs: restore-keys: | ${{ runner.os }}-deno_std-2- + - name: Install Rust for typst-gather + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.81" + + - name: Cache Cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + package/typst-gather/target + key: ${{ runner.os }}-cargo-typst-gather-${{ hashFiles('package/typst-gather/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-typst-gather- + - name: Configure Quarto (.sh) if: runner.os != 'Windows' shell: bash @@ -33,13 +49,13 @@ runs: - name: Basic dev mode sanity check shell: pwsh run: | - If ( "$(quarto --version)" -ne "99.9.9") { + If ( "$(quarto --version)" -ne "99.9.9") { echo "Unexpected version detected: $(quarto --version)" - Exit 1 + Exit 1 } - If ( $(quarto --paths | Select-String -Pattern "package[/\\]+dist[/\\]+share") -ne $null ) { + If ( $(quarto --paths | Select-String -Pattern "package[/\\]+dist[/\\]+share") -ne $null ) { echo "Unexpected package/dist/share path detected: $(quarto --paths)" - Exit 1 + Exit 1 } # check if configure is modifying some files as it should not $modifiedFiles = git diff --name-only diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 1290f78f157..8424e38c632 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -133,6 +133,11 @@ jobs: if: ${{ inputs.publish-release }} uses: ./.github/workflows/actions/prevent-rerun + - name: Install Rust for typst-gather + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.81" + - name: Configure run: | ./configure.sh @@ -261,6 +266,11 @@ jobs: if: ${{ inputs.publish-release }} uses: ./.github/workflows/actions/prevent-rerun + - name: Install Rust for typst-gather + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.81" + - name: Configure run: | ./configure.sh @@ -360,10 +370,11 @@ jobs: if: ${{ inputs.publish-release }} uses: ./.github/workflows/actions/prevent-rerun - - name: Configure Rust Tools - run: | - rustup.exe toolchain install 1.63.0 --component rustfmt --component clippy --no-self-update - rustup.exe default 1.63.0 + - name: Install Rust for typst-gather and launcher + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.81" + - name: Configure run: | .\configure.cmd @@ -389,6 +400,7 @@ jobs: ./package/pkg-working/bin/tools/x86_64/esbuild.exe ./package/pkg-working/bin/tools/x86_64/dart-sass/src/dart.exe ./package/pkg-working/bin/tools/x86_64/deno_dom/plugin.dll + ./package/pkg-working/bin/tools/x86_64/typst-gather.exe ./package/pkg-working/bin/tools/pandoc.exe ./package/pkg-working/bin/quarto.js env: @@ -491,6 +503,11 @@ jobs: if: ${{ inputs.publish-release }} uses: ./.github/workflows/actions/prevent-rerun + - name: Install Rust for typst-gather + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.81" + - name: Configure run: | ./configure.sh diff --git a/configure.cmd b/configure.cmd index 90c67409511..b4c2e839d03 100644 --- a/configure.cmd +++ b/configure.cmd @@ -89,6 +89,16 @@ IF EXIST !QUARTO_BIN_PATH!\quarto.cmd ( ECHO NOTE: To use quarto please use quarto.cmd (located in this folder) or add the following path to your PATH ECHO !QUARTO_BIN_PATH! +REM Build typst-gather if cargo is available +where cargo >nul 2>nul +if %ERRORLEVEL% EQU 0 ( + ECHO Building typst-gather... + cargo build --release --manifest-path package\typst-gather\Cargo.toml +) else ( + ECHO Note: Rust/cargo not found, skipping typst-gather build + ECHO Install Rust to use 'quarto call typst-gather' +) + endlocal & set QUARTO_BIN_DEV=%QUARTO_BIN_PATH% GOTO :eof diff --git a/configure.sh b/configure.sh index 859f3a3f769..4bbcd86c7bc 100755 --- a/configure.sh +++ b/configure.sh @@ -102,3 +102,12 @@ else export QUARTO_DENO_EXTRA_OPTIONS="--reload" quarto --version fi + +# Build typst-gather if cargo is available +if command -v cargo &> /dev/null; then + echo "Building typst-gather..." + cargo build --release --manifest-path package/typst-gather/Cargo.toml +else + echo "Note: Rust/cargo not found, skipping typst-gather build" + echo "Install Rust to use 'quarto call typst-gather'" +fi diff --git a/package/src/common/prepare-dist.ts b/package/src/common/prepare-dist.ts index 5509318abad..025f18247f2 100755 --- a/package/src/common/prepare-dist.ts +++ b/package/src/common/prepare-dist.ts @@ -100,6 +100,30 @@ export async function prepareDist( } } + // Stage typst-gather binary if it exists (built by configure.sh) + // Only stage if the build machine architecture matches the target architecture + // (cross-compilation is not currently supported) + const buildArch = Deno.build.arch === "aarch64" ? "aarch64" : "x86_64"; + if (buildArch === config.arch) { + const typstGatherBinaryName = config.os === "windows" ? "typst-gather.exe" : "typst-gather"; + const typstGatherSrc = join( + config.directoryInfo.root, + "package/typst-gather/target/release", + typstGatherBinaryName, + ); + if (existsSync(typstGatherSrc)) { + info("\nStaging typst-gather binary"); + const typstGatherDest = join(targetDir, config.arch, typstGatherBinaryName); + ensureDirSync(join(targetDir, config.arch)); + copySync(typstGatherSrc, typstGatherDest, { overwrite: true }); + info(`Copied ${typstGatherSrc} to ${typstGatherDest}`); + } else { + info("\nNote: typst-gather binary not found, skipping staging"); + } + } else { + info(`\nNote: Skipping typst-gather staging (build arch ${buildArch} != target arch ${config.arch})`); + } + // build quarto-preview.js info("Building Quarto Web UI"); const result = buildQuartoPreviewJs(config.directoryInfo.src, undefined, true); diff --git a/package/typst-gather/.gitignore b/package/typst-gather/.gitignore new file mode 100644 index 00000000000..ea8c4bf7f35 --- /dev/null +++ b/package/typst-gather/.gitignore @@ -0,0 +1 @@ +/target diff --git a/package/typst-gather/Cargo.lock b/package/typst-gather/Cargo.lock new file mode 100644 index 00000000000..77b2bb0277f --- /dev/null +++ b/package/typst-gather/Cargo.lock @@ -0,0 +1,3000 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "biblatex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d0c374feba1b9a59042a7c1cf00ce7c34b977b9134fe7c42b08e5183729f66" +dependencies = [ + "paste", + "roman-numerals-rs", + "strum", + "unic-langid", + "unicode-normalization", + "unscanny", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chinese-number" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e964125508474a83c95eb935697abbeb446ff4e9d62c71ce880e3986d1c606b" +dependencies = [ + "chinese-variant", + "enum-ordinalize", + "num-bigint", + "num-traits", +] + +[[package]] +name = "chinese-variant" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b52a9840ffff5d4d0058ae529fa066a75e794e3125546acfc61c23ad755e49" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "citationberg" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f6597e8bdbca37f1f56e5a80d15857b0932aead21a78d20de49e99e74933046" +dependencies = [ + "quick-xml", + "serde", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "codex" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9589e1effc5cacbea347899645c654158b03b2053d24bb426fd3128ced6e423c" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "comemo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "649d7b2d867b569729c03c0f6968db10bc95921182a1f2b2012b1b549492f39d" +dependencies = [ + "comemo-macros", + "parking_lot", + "rustc-hash", + "siphasher", + "slab", +] + +[[package]] +name = "comemo-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c87fc7e85487493ddedae1a3a34b897c77ad8825375b79265a8a162c28d535" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ecow" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78e4f79b296fbaab6ce2e22d52cb4c7f010fe0ebe7a32e34fa25885fd797bd02" +dependencies = [ + "serde", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_proxy" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a5019be18538406a43b5419a5501461f0c8b49ea7dfda0cfc32f4e51fc44be1" +dependencies = [ + "log", + "url", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hayagriva" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb69425736f184173b3ca6e27fcba440a61492a790c786b1c6af7e06a03e575" +dependencies = [ + "biblatex", + "ciborium", + "citationberg", + "indexmap", + "paste", + "roman-numerals-rs", + "serde", + "serde_yaml", + "thiserror", + "unic-langid", + "unicode-segmentation", + "unscanny", + "url", +] + +[[package]] +name = "hayro-syntax" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9e5c7dbc0f11dc42775d1a6cc00f5f5137b90b6288dd7fe5f71d17b14d10be" +dependencies = [ + "flate2", + "kurbo 0.12.0", + "log", + "rustc-hash", + "smallvec", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "serde", + "yoke 0.7.5", + "zerofrom", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke 0.8.1", + "zerofrom", + "zerovec 0.11.5", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap 0.8.1", + "tinystr 0.8.2", + "writeable 0.6.2", + "zerovec 0.11.5", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap 0.7.5", + "tinystr 0.7.6", + "writeable 0.5.5", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider 1.5.0", + "tinystr 0.7.6", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections 2.1.1", + "icu_normalizer_data", + "icu_properties 2.1.2", + "icu_provider 2.1.1", + "smallvec", + "zerovec 0.11.5", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections 1.5.0", + "icu_locid_transform", + "icu_properties_data 1.5.1", + "icu_provider 1.5.0", + "serde", + "tinystr 0.7.6", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections 2.1.1", + "icu_locale_core", + "icu_properties_data 2.1.2", + "icu_provider 2.1.1", + "zerotrie 0.2.3", + "zerovec 0.11.5", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "postcard", + "serde", + "stable_deref_trait", + "tinystr 0.7.6", + "writeable 0.5.5", + "yoke 0.7.5", + "zerofrom", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable 0.6.2", + "yoke 0.8.1", + "zerofrom", + "zerotrie 0.2.3", + "zerovec 0.11.5", +] + +[[package]] +name = "icu_provider_blob" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c24b98d1365f55d78186c205817631a4acf08d7a45bdf5dc9dcf9c5d54dccf51" +dependencies = [ + "icu_provider 1.5.0", + "postcard", + "serde", + "writeable 0.5.5", + "zerotrie 0.1.3", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties 2.1.2", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.0", + "zune-core 0.5.0", + "zune-jpeg 0.5.8", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lipsum" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636860251af8963cc40f6b4baadee105f02e21b28131d76eba8e40ce84ab8064" +dependencies = [ + "rand", + "rand_chacha", +] + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +dependencies = [ + "serde", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec 0.11.5", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "qcms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edecfcd5d755a5e5d98e24cf43113e7cdaec5a070edd0f6b250c03a573da30fa" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "roman-numerals-rs" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85cd47a33a4510b1424fe796498e174c6a9cf94e606460ef022a19f3e4ff85e" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rust_decimal" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thin-vec" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "serde", + "zerovec 0.10.4", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec 0.11.5", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "two-face" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e51b6e60e545cfdae5a4639ff423818f52372211a8d9a3e892b4b0761f76b2" +dependencies = [ + "serde", + "serde_derive", + "syntect", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typst-assets" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5613cb719a6222fe9b74027c3625d107767ec187bff26b8fc931cf58942c834f" + +[[package]] +name = "typst-gather" +version = "0.1.2" +dependencies = [ + "clap", + "ecow", + "globset", + "serde", + "tempfile", + "toml", + "typst-kit", + "typst-syntax", + "walkdir", +] + +[[package]] +name = "typst-kit" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31476ec753e080ffdd543a0e74b6d319355449ff3eca3f216634f31cfd09a92a" +dependencies = [ + "dirs", + "ecow", + "env_proxy", + "fastrand", + "flate2", + "fontdb", + "native-tls", + "once_cell", + "openssl", + "serde", + "serde_json", + "tar", + "typst-library", + "typst-syntax", + "typst-timing", + "typst-utils", + "ureq", +] + +[[package]] +name = "typst-library" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e276a5de53020c43efe2111ec236252e54ea4480b5ac18063e663dfbe03d9d1b" +dependencies = [ + "az", + "bitflags 2.10.0", + "bumpalo", + "chinese-number", + "ciborium", + "codex", + "comemo", + "csv", + "ecow", + "flate2", + "fontdb", + "glidesort", + "hayagriva", + "hayro-syntax", + "icu_properties 1.5.1", + "icu_provider 1.5.0", + "icu_provider_blob", + "image", + "indexmap", + "kamadak-exif", + "kurbo 0.12.0", + "lipsum", + "memchr", + "palette", + "phf", + "png 0.17.16", + "qcms", + "rayon", + "regex", + "regex-syntax", + "roxmltree", + "rust_decimal", + "rustc-hash", + "rustybuzz", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "smallvec", + "syntect", + "time", + "toml", + "ttf-parser", + "two-face", + "typed-arena", + "typst-assets", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-math-class", + "unicode-normalization", + "unicode-segmentation", + "unscanny", + "usvg", + "utf8_iter", + "wasmi", + "xmlwriter", +] + +[[package]] +name = "typst-macros" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "141cbd1027129fbf6bda1013f52a264df7befc7388cc8f47767d65e803fd3a59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typst-syntax" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a95d9192060e23b1e491b0b94dff676acddc92a4d672aeb8ca3890a5a734e879" +dependencies = [ + "ecow", + "rustc-hash", + "serde", + "toml", + "typst-timing", + "typst-utils", + "unicode-ident", + "unicode-math-class", + "unicode-script", + "unicode-segmentation", + "unscanny", +] + +[[package]] +name = "typst-timing" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be94f8faf19841b49574ef5c7fd7a12e2deb7c3d8deba5a596f35d2222024cd" +dependencies = [ + "parking_lot", + "serde", + "serde_json", +] + +[[package]] +name = "typst-utils" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3966c92e8fa48c7ce898130d07000d985f18206d92b250f0f939287fbccdee3" +dependencies = [ + "once_cell", + "portable-atomic", + "rayon", + "rustc-hash", + "siphasher", + "thin-vec", + "unicode-math-class", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr 0.8.2", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr 0.8.2", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-math-class" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d246cf599d5fae3c8d56e04b20eb519adb89a8af8d0b0fbcded369aa3647d65" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "native-tls", + "once_cell", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasmi" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser", +] + +[[package]] +name = "wasmi_collections" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b8e98e45a2a534489f8225e765cbf1cb9a3078072605e58158910cf4749172" + +[[package]] +name = "wasmi_core" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25f375c0cdf14810eab07f532f61f14d4966f09c747a55067fdf3196e8512e6" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624e2a68a4293ecb8f564260b68394b29cf3b3edba6bce35532889a2cb33c3d9" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive 0.8.1", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb594dd55d87335c5f60177cee24f19457a5ec10a065e0a3014722ad252d0a1f" +dependencies = [ + "displaydoc", + "litemap 0.7.5", + "serde", + "zerovec 0.10.4", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke 0.8.1", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "serde", + "yoke 0.7.5", + "zerofrom", + "zerovec-derive 0.10.3", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "serde", + "yoke 0.8.1", + "zerofrom", + "zerovec-derive 0.11.2", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d6085d62852e35540689d1f97ad663e3971fc19cf5eceab364d62c646ea167" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +dependencies = [ + "zune-core 0.5.0", +] diff --git a/package/typst-gather/Cargo.toml b/package/typst-gather/Cargo.toml new file mode 100644 index 00000000000..90316d4a382 --- /dev/null +++ b/package/typst-gather/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "typst-gather" +version = "0.1.2" +edition = "2021" +rust-version = "1.81" + +[dependencies] +typst-kit = { version = "0.14.2", features = ["packages"] } +typst-syntax = "0.14.2" +ecow = "0.2" +serde = { version = "1", features = ["derive"] } +clap = { version = "4", features = ["derive"] } +toml = "0.8" +walkdir = "2" +globset = "0.4" + +[dev-dependencies] +tempfile = "3" diff --git a/package/typst-gather/README.md b/package/typst-gather/README.md new file mode 100644 index 00000000000..8efab9c0ca7 --- /dev/null +++ b/package/typst-gather/README.md @@ -0,0 +1,62 @@ +# typst-gather + +Gather Typst packages locally for offline/hermetic builds. + +## Install + +```bash +cargo install --path . +``` + +## Usage + +```bash +typst-gather packages.toml +``` + +Then set `TYPST_PACKAGE_CACHE_PATH` to the destination directory when running Typst. + +## TOML format + +```toml +destination = "/path/to/packages" + +# Single path +discover = "/path/to/templates" + +# Or array of paths (files or directories) +discover = ["template.typ", "typst-show.typ", "/path/to/dir"] + +[preview] +cetz = "0.4.1" +fontawesome = "0.5.0" + +[local] +my-template = "/path/to/src" +``` + +- `destination` - Required. Directory where packages will be gathered. +- `discover` - Optional. Paths to scan for imports. Can be: + - A single string path + - An array of paths + - Each path can be a `.typ` file or a directory (scans `.typ` files non-recursively) +- `[preview]` packages are downloaded from Typst Universe (cached - skipped if already present) +- `[local]` packages are copied from the specified directory (always fresh - version read from `typst.toml`) + +## Features + +- Recursively resolves `@preview` dependencies from `#import` statements +- Uses Typst's own parser for reliable import detection +- Discover mode scans .typ files for imports +- Local packages always overwrite (clean slate) +- Preview packages skip if already cached + +## Quarto Integration + +When used with Quarto extensions, you can run: + +```bash +quarto call typst-gather +``` + +This will auto-detect `.typ` files from `_extension.yml` (template and template-partials) and gather their dependencies. diff --git a/package/typst-gather/example-packages.toml b/package/typst-gather/example-packages.toml new file mode 100644 index 00000000000..da19f2f95e4 --- /dev/null +++ b/package/typst-gather/example-packages.toml @@ -0,0 +1,8 @@ +destination = "/path/to/cache" + +[preview] +cetz = "0.4.1" +tablex = "0.0.8" + +[local] +my-pkg = "/path/to/my-pkg" diff --git a/package/typst-gather/src/lib.rs b/package/typst-gather/src/lib.rs new file mode 100644 index 00000000000..1370620d6ab --- /dev/null +++ b/package/typst-gather/src/lib.rs @@ -0,0 +1,833 @@ +//! typst-gather: Gather Typst packages locally for offline/hermetic builds. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::env; +use std::path::{Path, PathBuf}; + +use ecow::EcoString; +use globset::{Glob, GlobSetBuilder}; +use serde::Deserialize; +use typst_kit::download::{Downloader, ProgressSink}; +use typst_kit::package::PackageStorage; +use typst_syntax::ast; +use typst_syntax::package::{PackageManifest, PackageSpec, PackageVersion}; +use typst_syntax::SyntaxNode; +use walkdir::WalkDir; + +/// Statistics about gathering operations. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Stats { + pub downloaded: usize, + pub copied: usize, + pub skipped: usize, + pub failed: usize, +} + +/// Result of a gather operation. +#[derive(Debug, Default)] +pub struct GatherResult { + pub stats: Stats, + /// @local imports discovered during scanning that are not configured in [local] section. + /// Each entry is (package_name, source_file_path). + pub unconfigured_local: Vec<(String, String)>, +} + +/// TOML configuration format. +/// +/// ```toml +/// destination = "/path/to/packages" +/// discover = ["/path/to/templates", "/path/to/other.typ"] +/// +/// [preview] +/// cetz = "0.4.1" +/// fletcher = "0.5.3" +/// +/// [local] +/// my-pkg = "/path/to/pkg" +/// ``` +/// Helper enum for deserializing string or array of strings +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum StringOrVec { + Single(String), + Multiple(Vec), +} + +impl Default for StringOrVec { + fn default() -> Self { + StringOrVec::Multiple(Vec::new()) + } +} + +impl From for Vec { + fn from(value: StringOrVec) -> Self { + match value { + StringOrVec::Single(s) => vec![PathBuf::from(s)], + StringOrVec::Multiple(v) => v.into_iter().map(PathBuf::from).collect(), + } + } +} + +/// Raw config for deserialization +#[derive(Debug, Deserialize, Default)] +struct RawConfig { + /// Root directory for resolving relative paths (discover, destination) + rootdir: Option, + destination: Option, + #[serde(default)] + discover: Option, + #[serde(default)] + preview: HashMap, + #[serde(default)] + local: HashMap, +} + +#[derive(Debug, Default)] +pub struct Config { + /// Root directory for resolving relative paths (discover, destination). + /// If set, discover and destination paths are resolved relative to this. + pub rootdir: Option, + /// Destination directory for gathered packages + pub destination: Option, + /// Paths to scan for imports. Can be directories (scans .typ files) or individual .typ files. + /// Accepts either a single path or an array of paths. + pub discover: Vec, + pub preview: HashMap, + pub local: HashMap, +} + +impl From for Config { + fn from(raw: RawConfig) -> Self { + Config { + rootdir: raw.rootdir, + destination: raw.destination, + discover: raw.discover.map(Into::into).unwrap_or_default(), + preview: raw.preview, + local: raw.local, + } + } +} + +/// A resolved package entry ready for gathering. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PackageEntry { + Preview { name: String, version: String }, + Local { name: String, dir: PathBuf }, +} + +impl Config { + /// Parse a TOML configuration string. + pub fn parse(content: &str) -> Result { + let raw: RawConfig = toml::from_str(content)?; + Ok(raw.into()) + } + + /// Convert config into a list of package entries. + pub fn into_entries(self) -> Vec { + let mut entries = Vec::new(); + + for (name, version) in self.preview { + entries.push(PackageEntry::Preview { name, version }); + } + + for (name, dir) in self.local { + entries.push(PackageEntry::Local { + name, + dir: PathBuf::from(dir), + }); + } + + entries + } +} + +/// Context for gathering operations, holding shared state. +struct GatherContext<'a> { + storage: PackageStorage, + dest: &'a Path, + configured_local: &'a HashSet, + processed: HashSet, + stats: Stats, + /// @local imports discovered during scanning (name -> source_file) + discovered_local: HashMap, +} + +impl<'a> GatherContext<'a> { + fn new(dest: &'a Path, configured_local: &'a HashSet) -> Self { + Self { + storage: PackageStorage::new( + Some(dest.to_path_buf()), + None, + Downloader::new("typst-gather/0.1.0"), + ), + dest, + configured_local, + processed: HashSet::new(), + stats: Stats::default(), + discovered_local: HashMap::new(), + } + } +} + +/// Gather packages to the destination directory. +pub fn gather_packages( + dest: &Path, + entries: Vec, + discover_paths: &[PathBuf], + configured_local: &HashSet, +) -> GatherResult { + let mut ctx = GatherContext::new(dest, configured_local); + + // First, process discover paths + for path in discover_paths { + discover_imports(&mut ctx, path); + } + + // Then process explicit entries + for entry in entries { + match entry { + PackageEntry::Preview { name, version } => { + cache_preview(&mut ctx, &name, &version); + } + PackageEntry::Local { name, dir } => { + gather_local(&mut ctx, &name, &dir); + } + } + } + + // Find @local imports that aren't configured + let unconfigured_local: Vec<(String, String)> = ctx.discovered_local + .into_iter() + .filter(|(name, _)| !ctx.configured_local.contains(name)) + .collect(); + + GatherResult { + stats: ctx.stats, + unconfigured_local, + } +} + +/// Scan a path for imports. If it's a directory, scans .typ files in it (non-recursive). +/// If it's a file, scans that file directly. +fn discover_imports(ctx: &mut GatherContext, path: &Path) { + if path.is_file() { + // Single file + if path.extension().is_some_and(|e| e == "typ") { + println!("Discovering imports in {}...", display_path(path)); + scan_file_for_imports(ctx, path); + } + } else if path.is_dir() { + // Directory - scan .typ files (non-recursive) + println!("Discovering imports in {}...", display_path(path)); + + let entries = match std::fs::read_dir(path) { + Ok(e) => e, + Err(e) => { + eprintln!(" Failed to read directory: {e}"); + ctx.stats.failed += 1; + return; + } + }; + + for entry in entries.flatten() { + let file_path = entry.path(); + if file_path.is_file() && file_path.extension().is_some_and(|e| e == "typ") { + scan_file_for_imports(ctx, &file_path); + } + } + } else { + eprintln!("Warning: discover path does not exist: {}", display_path(path)); + } +} + +/// Scan a single .typ file for @preview and @local imports. +/// @preview imports are cached, @local imports are tracked for later warning. +fn scan_file_for_imports(ctx: &mut GatherContext, path: &Path) { + if let Ok(content) = std::fs::read_to_string(path) { + let mut imports = Vec::new(); + collect_imports(&typst_syntax::parse(&content), &mut imports); + + let source_file = path.file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + + for spec in imports { + if spec.namespace == "preview" { + cache_preview_with_deps(ctx, &spec); + } else if spec.namespace == "local" { + // Track @local imports (only first occurrence per package name) + ctx.discovered_local.entry(spec.name.to_string()) + .or_insert(source_file.clone()); + } + } + } +} + +fn cache_preview(ctx: &mut GatherContext, name: &str, version_str: &str) { + let Ok(version): Result = version_str.parse() else { + eprintln!("Invalid version '{version_str}' for @preview/{name}"); + ctx.stats.failed += 1; + return; + }; + + let spec = PackageSpec { + namespace: EcoString::from("preview"), + name: EcoString::from(name), + version, + }; + + cache_preview_with_deps(ctx, &spec); +} + +/// Default exclude patterns for local packages (common non-package files). +const DEFAULT_EXCLUDES: &[&str] = &[ + ".git", + ".git/**", + ".github", + ".github/**", + ".gitignore", + ".gitattributes", + ".vscode", + ".vscode/**", + ".idea", + ".idea/**", + "*.bak", + "*.swp", + "*~", +]; + +fn gather_local(ctx: &mut GatherContext, name: &str, src_dir: &Path) { + // Read typst.toml to get version (and validate name) + let manifest_path = src_dir.join("typst.toml"); + let manifest: PackageManifest = match std::fs::read_to_string(&manifest_path) + .map_err(|e| e.to_string()) + .and_then(|s| toml::from_str(&s).map_err(|e| e.to_string())) + { + Ok(m) => m, + Err(e) => { + eprintln!("Error reading typst.toml for @local/{name}: {e}"); + ctx.stats.failed += 1; + return; + } + }; + + // Validate name matches + if manifest.package.name.as_str() != name { + eprintln!( + "Name mismatch for @local/{name}: typst.toml has '{}'", + manifest.package.name + ); + ctx.stats.failed += 1; + return; + } + + let version = manifest.package.version; + let dest_dir = ctx.dest.join(format!("local/{name}/{version}")); + + println!("Copying @local/{name}:{version}..."); + + // Clean slate: remove destination if exists + if dest_dir.exists() { + if let Err(e) = std::fs::remove_dir_all(&dest_dir) { + eprintln!(" Failed to remove existing dir: {e}"); + ctx.stats.failed += 1; + return; + } + } + + // Build exclude pattern matcher from defaults + manifest excludes + let mut builder = GlobSetBuilder::new(); + for pattern in DEFAULT_EXCLUDES { + if let Ok(glob) = Glob::new(pattern) { + builder.add(glob); + } + } + // Add manifest excludes if present + for pattern in &manifest.package.exclude { + if let Ok(glob) = Glob::new(pattern.as_str()) { + builder.add(glob); + } + } + let excludes = builder.build().unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap()); + + // Copy files, respecting exclude patterns + if let Err(e) = copy_filtered(src_dir, &dest_dir, &excludes) { + eprintln!(" Failed to copy: {e}"); + ctx.stats.failed += 1; + return; + } + + println!(" -> {}", display_path(&dest_dir)); + ctx.stats.copied += 1; + + // Mark as processed + let spec = PackageSpec { + namespace: EcoString::from("local"), + name: EcoString::from(name), + version, + }; + ctx.processed.insert(spec.to_string()); + + // Scan for @preview dependencies + scan_deps(ctx, &dest_dir); +} + +/// Copy directory contents, excluding files that match the exclude patterns. +fn copy_filtered( + src: &Path, + dest: &Path, + excludes: &globset::GlobSet, +) -> std::io::Result<()> { + std::fs::create_dir_all(dest)?; + + for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + let relative = path.strip_prefix(src).unwrap_or(path); + + // Check if this path matches any exclude pattern + if excludes.is_match(relative) { + continue; + } + + let dest_path = dest.join(relative); + + if path.is_dir() { + std::fs::create_dir_all(&dest_path)?; + } else if path.is_file() { + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(path, &dest_path)?; + } + } + + Ok(()) +} + +fn cache_preview_with_deps(ctx: &mut GatherContext, spec: &PackageSpec) { + // Skip @preview packages that are configured as @local (use local version instead) + if ctx.configured_local.contains(spec.name.as_str()) { + return; + } + + let key = spec.to_string(); + if !ctx.processed.insert(key) { + return; + } + + let subdir = format!("{}/{}/{}", spec.namespace, spec.name, spec.version); + let cached_path = ctx.storage.package_cache_path().map(|p| p.join(&subdir)); + + if cached_path.as_ref().is_some_and(|p| p.exists()) { + println!("Skipping {spec} (cached)"); + ctx.stats.skipped += 1; + scan_deps(ctx, cached_path.as_ref().unwrap()); + return; + } + + println!("Downloading {spec}..."); + match ctx.storage.prepare_package(spec, &mut ProgressSink) { + Ok(path) => { + println!(" -> {}", display_path(&path)); + ctx.stats.downloaded += 1; + scan_deps(ctx, &path); + } + Err(e) => { + eprintln!(" Failed: {e:?}"); + ctx.stats.failed += 1; + } + } +} + +fn scan_deps(ctx: &mut GatherContext, dir: &Path) { + for spec in find_imports(dir) { + if spec.namespace == "preview" { + cache_preview_with_deps(ctx, &spec); + } + } +} + +/// Display a path relative to the current working directory. +fn display_path(path: &Path) -> String { + if let Ok(cwd) = env::current_dir() { + if let Ok(relative) = path.strip_prefix(&cwd) { + return relative.display().to_string(); + } + } + path.display().to_string() +} + +/// Find all package imports in `.typ` files under a directory. +pub fn find_imports(dir: &Path) -> Vec { + let mut imports = Vec::new(); + for entry in WalkDir::new(dir).into_iter().flatten() { + if entry.path().extension().is_some_and(|e| e == "typ") { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + collect_imports(&typst_syntax::parse(&content), &mut imports); + } + } + } + imports +} + +/// Extract package imports from a Typst syntax tree. +pub fn collect_imports(node: &SyntaxNode, imports: &mut Vec) { + if let Some(import) = node.cast::() { + if let Some(spec) = try_extract_spec(import.source()) { + imports.push(spec); + } + } + if let Some(include) = node.cast::() { + if let Some(spec) = try_extract_spec(include.source()) { + imports.push(spec); + } + } + for child in node.children() { + collect_imports(child, imports); + } +} + +/// Try to extract a PackageSpec from an expression (if it's an `@namespace/name:version` string). +pub fn try_extract_spec(expr: ast::Expr) -> Option { + if let ast::Expr::Str(s) = expr { + let val = s.get(); + if val.starts_with('@') { + return val.parse().ok(); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + mod config_parsing { + use super::*; + + #[test] + fn empty_config() { + let config = Config::parse("").unwrap(); + assert!(config.destination.is_none()); + assert!(config.discover.is_empty()); + assert!(config.preview.is_empty()); + assert!(config.local.is_empty()); + } + + #[test] + fn destination_only() { + let toml = r#"destination = "/path/to/cache""#; + let config = Config::parse(toml).unwrap(); + assert_eq!(config.destination, Some(PathBuf::from("/path/to/cache"))); + assert!(config.discover.is_empty()); + assert!(config.preview.is_empty()); + assert!(config.local.is_empty()); + } + + #[test] + fn with_discover_string() { + let toml = r#" +destination = "/cache" +discover = "/path/to/templates" +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!(config.destination, Some(PathBuf::from("/cache"))); + assert_eq!(config.discover, vec![PathBuf::from("/path/to/templates")]); + } + + #[test] + fn with_discover_array() { + let toml = r#" +destination = "/cache" +discover = ["/path/to/templates", "template.typ", "other.typ"] +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!(config.destination, Some(PathBuf::from("/cache"))); + assert_eq!( + config.discover, + vec![ + PathBuf::from("/path/to/templates"), + PathBuf::from("template.typ"), + PathBuf::from("other.typ"), + ] + ); + } + + #[test] + fn preview_only() { + let toml = r#" +destination = "/cache" + +[preview] +cetz = "0.4.1" +fletcher = "0.5.3" +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!(config.destination, Some(PathBuf::from("/cache"))); + assert_eq!(config.preview.len(), 2); + assert_eq!(config.preview.get("cetz"), Some(&"0.4.1".to_string())); + assert_eq!(config.preview.get("fletcher"), Some(&"0.5.3".to_string())); + assert!(config.local.is_empty()); + } + + #[test] + fn local_only() { + let toml = r#" +destination = "/cache" + +[local] +my-pkg = "/path/to/pkg" +other = "../relative/path" +"#; + let config = Config::parse(toml).unwrap(); + assert!(config.preview.is_empty()); + assert_eq!(config.local.len(), 2); + assert_eq!(config.local.get("my-pkg"), Some(&"/path/to/pkg".to_string())); + assert_eq!(config.local.get("other"), Some(&"../relative/path".to_string())); + } + + #[test] + fn mixed_config() { + let toml = r#" +destination = "/cache" + +[preview] +cetz = "0.4.1" + +[local] +my-pkg = "/path/to/pkg" +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!(config.destination, Some(PathBuf::from("/cache"))); + assert_eq!(config.preview.len(), 1); + assert_eq!(config.local.len(), 1); + } + + #[test] + fn into_entries() { + let toml = r#" +destination = "/cache" + +[preview] +cetz = "0.4.1" + +[local] +my-pkg = "/path/to/pkg" +"#; + let config = Config::parse(toml).unwrap(); + let entries = config.into_entries(); + assert_eq!(entries.len(), 2); + + let has_preview = entries.iter().any(|e| { + matches!(e, PackageEntry::Preview { name, version } + if name == "cetz" && version == "0.4.1") + }); + let has_local = entries.iter().any(|e| { + matches!(e, PackageEntry::Local { name, dir } + if name == "my-pkg" && dir == Path::new("/path/to/pkg")) + }); + assert!(has_preview); + assert!(has_local); + } + + #[test] + fn invalid_toml() { + let result = Config::parse("not valid toml [[["); + assert!(result.is_err()); + } + + #[test] + fn extra_fields_ignored() { + let toml = r#" +destination = "/cache" + +[preview] +cetz = "0.4.1" + +[unknown_section] +foo = "bar" +"#; + // Should not error on unknown sections + let config = Config::parse(toml).unwrap(); + assert_eq!(config.preview.len(), 1); + } + } + + mod import_parsing { + use super::*; + + fn parse_imports(code: &str) -> Vec { + let mut imports = Vec::new(); + collect_imports(&typst_syntax::parse(code), &mut imports); + imports + } + + #[test] + fn simple_import() { + let imports = parse_imports(r#"#import "@preview/cetz:0.4.1""#); + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].namespace, "preview"); + assert_eq!(imports[0].name, "cetz"); + assert_eq!(imports[0].version.to_string(), "0.4.1"); + } + + #[test] + fn import_with_items() { + let imports = parse_imports(r#"#import "@preview/cetz:0.4.1": canvas, draw"#); + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].name, "cetz"); + } + + #[test] + fn multiple_imports() { + let code = r#" +#import "@preview/cetz:0.4.1" +#import "@preview/fletcher:0.5.3" +"#; + let imports = parse_imports(code); + assert_eq!(imports.len(), 2); + } + + #[test] + fn include_statement() { + let imports = parse_imports(r#"#include "@preview/template:1.0.0""#); + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].name, "template"); + } + + #[test] + fn local_import_ignored_in_extract() { + // Local imports are valid but won't be recursively fetched + let imports = parse_imports(r#"#import "@local/my-pkg:1.0.0""#); + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].namespace, "local"); + } + + #[test] + fn relative_import_ignored() { + let imports = parse_imports(r#"#import "utils.typ""#); + assert_eq!(imports.len(), 0); + } + + #[test] + fn no_imports() { + let imports = parse_imports(r#"= Hello World"#); + assert_eq!(imports.len(), 0); + } + + #[test] + fn nested_in_function() { + let code = r#" +#let setup() = { + import "@preview/cetz:0.4.1" +} +"#; + let imports = parse_imports(code); + assert_eq!(imports.len(), 1); + } + + #[test] + fn invalid_package_spec_ignored() { + // Missing version + let imports = parse_imports(r#"#import "@preview/cetz""#); + assert_eq!(imports.len(), 0); + } + + #[test] + fn complex_document() { + let code = r#" +#import "@preview/cetz:0.4.1": canvas +#import "@preview/fletcher:0.5.3": diagram, node, edge +#import "local-file.typ": helper + += My Document + +#include "@preview/template:1.0.0" + +Some content here. + +#let f() = { + import "@preview/codly:1.2.0" +} +"#; + let imports = parse_imports(code); + assert_eq!(imports.len(), 4); + + let names: Vec<_> = imports.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"cetz")); + assert!(names.contains(&"fletcher")); + assert!(names.contains(&"template")); + assert!(names.contains(&"codly")); + } + } + + mod stats { + use super::*; + + #[test] + fn default_stats() { + let stats = Stats::default(); + assert_eq!(stats.downloaded, 0); + assert_eq!(stats.copied, 0); + assert_eq!(stats.skipped, 0); + assert_eq!(stats.failed, 0); + } + } + + mod local_override { + use super::*; + + /// When a package is configured in [local], @preview imports of the same + /// package name should be skipped. This handles the case where a local + /// package contains template examples that import from @preview. + #[test] + fn configured_local_contains_check() { + let mut configured_local = HashSet::new(); + configured_local.insert("my-pkg".to_string()); + configured_local.insert("other-pkg".to_string()); + + // These should be skipped (configured as local) + assert!(configured_local.contains("my-pkg")); + assert!(configured_local.contains("other-pkg")); + + // These should NOT be skipped (not configured) + assert!(!configured_local.contains("cetz")); + assert!(!configured_local.contains("fletcher")); + } + } + + mod copy_filtering { + use super::*; + + #[test] + fn default_excludes_match_git() { + let mut builder = GlobSetBuilder::new(); + for pattern in DEFAULT_EXCLUDES { + builder.add(Glob::new(pattern).unwrap()); + } + let excludes = builder.build().unwrap(); + + // Should match .git and contents + assert!(excludes.is_match(".git")); + assert!(excludes.is_match(".git/config")); + assert!(excludes.is_match(".git/objects/pack/foo")); + + // Should match .github + assert!(excludes.is_match(".github")); + assert!(excludes.is_match(".github/workflows/ci.yml")); + + // Should match editor files + assert!(excludes.is_match(".gitignore")); + assert!(excludes.is_match("foo.bak")); + assert!(excludes.is_match("foo.swp")); + assert!(excludes.is_match("foo~")); + + // Should NOT match normal files + assert!(!excludes.is_match("lib.typ")); + assert!(!excludes.is_match("typst.toml")); + assert!(!excludes.is_match("src/main.typ")); + assert!(!excludes.is_match("template/main.typ")); + } + } +} diff --git a/package/typst-gather/src/main.rs b/package/typst-gather/src/main.rs new file mode 100644 index 00000000000..07d951022a9 --- /dev/null +++ b/package/typst-gather/src/main.rs @@ -0,0 +1,87 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; +use typst_gather::{gather_packages, Config}; + +#[derive(Parser)] +#[command(version, about = "Gather Typst packages to a local directory")] +struct Args { + /// TOML file specifying packages to gather + spec_file: PathBuf, +} + +fn main() -> ExitCode { + let args = Args::parse(); + + let content = match std::fs::read_to_string(&args.spec_file) { + Ok(c) => c, + Err(e) => { + eprintln!("Error reading spec file: {e}"); + return ExitCode::FAILURE; + } + }; + + let config = match Config::parse(&content) { + Ok(c) => c, + Err(e) => { + eprintln!("Error parsing spec file: {e}"); + return ExitCode::FAILURE; + } + }; + + let dest = match &config.destination { + Some(d) => d.clone(), + None => { + eprintln!("Error: 'destination' field is required in spec file"); + return ExitCode::FAILURE; + } + }; + + // Resolve paths relative to rootdir if specified + let rootdir = config.rootdir.clone(); + let dest = match &rootdir { + Some(root) => root.join(&dest), + None => dest, + }; + let discover: Vec = config + .discover + .iter() + .map(|p| match &rootdir { + Some(root) => root.join(p), + None => p.clone(), + }) + .collect(); + + // Build set of configured local packages + let configured_local: HashSet = config.local.keys().cloned().collect(); + + let entries = config.into_entries(); + let result = gather_packages(&dest, entries, &discover, &configured_local); + + // Check for unconfigured @local imports FIRST (this is an error) + if !result.unconfigured_local.is_empty() { + eprintln!("\nError: Found @local imports not configured in [local] section:"); + for (name, source_file) in &result.unconfigured_local { + eprintln!(" - {name} (in {source_file})"); + } + eprintln!("\nAdd them to your config file:"); + eprintln!(" [local]"); + for (name, _) in &result.unconfigured_local { + eprintln!(" {name} = \"/path/to/{name}\""); + } + return ExitCode::FAILURE; + } + + println!( + "\nDone: {} downloaded, {} copied, {} skipped, {} failed", + result.stats.downloaded, result.stats.copied, result.stats.skipped, result.stats.failed + ); + + if result.stats.failed > 0 { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} diff --git a/package/typst-gather/tests/integration.rs b/package/typst-gather/tests/integration.rs new file mode 100644 index 00000000000..1d4c09e1198 --- /dev/null +++ b/package/typst-gather/tests/integration.rs @@ -0,0 +1,543 @@ +//! Integration tests for typst-gather. +//! +//! These tests verify the full gathering workflow including: +//! - Local package copying +//! - Dependency scanning from .typ files +//! - Preview package caching (requires network) + +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use tempfile::TempDir; +use typst_gather::{gather_packages, find_imports, Config, PackageEntry}; + +/// Helper to create a minimal local package with typst.toml +fn create_local_package(dir: &Path, name: &str, version: &str, typ_content: Option<&str>) { + fs::create_dir_all(dir).unwrap(); + + let manifest = format!( + r#"[package] +name = "{name}" +version = "{version}" +entrypoint = "lib.typ" +"# + ); + fs::write(dir.join("typst.toml"), manifest).unwrap(); + + let content = typ_content.unwrap_or("// Empty package\n"); + fs::write(dir.join("lib.typ"), content).unwrap(); +} + +mod local_packages { + use super::*; + + #[test] + fn cache_single_local_package() { + let src_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + + create_local_package(src_dir.path(), "my-pkg", "1.0.0", None); + + let entries = vec![PackageEntry::Local { + name: "my-pkg".to_string(), + dir: src_dir.path().to_path_buf(), + }]; + + let configured_local: HashSet = ["my-pkg".to_string()].into_iter().collect(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.copied, 1); + assert_eq!(result.stats.failed, 0); + + // Verify package was copied to correct location + let cached = cache_dir.path().join("local/my-pkg/1.0.0"); + assert!(cached.exists()); + assert!(cached.join("typst.toml").exists()); + assert!(cached.join("lib.typ").exists()); + } + + #[test] + fn cache_local_package_overwrites_existing() { + let src_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + + // Create initial version + create_local_package(src_dir.path(), "my-pkg", "1.0.0", Some("// v1")); + + let entries = vec![PackageEntry::Local { + name: "my-pkg".to_string(), + dir: src_dir.path().to_path_buf(), + }]; + + let configured_local: HashSet = ["my-pkg".to_string()].into_iter().collect(); + gather_packages(cache_dir.path(), entries.clone(), &[], &configured_local); + + // Update source + fs::write(src_dir.path().join("lib.typ"), "// v2").unwrap(); + + // Cache again + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + assert_eq!(result.stats.copied, 1); + + // Verify new content + let cached_lib = cache_dir.path().join("local/my-pkg/1.0.0/lib.typ"); + let content = fs::read_to_string(cached_lib).unwrap(); + assert_eq!(content, "// v2"); + } + + #[test] + fn cache_multiple_local_packages() { + let src1 = TempDir::new().unwrap(); + let src2 = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + + create_local_package(src1.path(), "pkg-one", "1.0.0", None); + create_local_package(src2.path(), "pkg-two", "2.0.0", None); + + let entries = vec![ + PackageEntry::Local { + name: "pkg-one".to_string(), + dir: src1.path().to_path_buf(), + }, + PackageEntry::Local { + name: "pkg-two".to_string(), + dir: src2.path().to_path_buf(), + }, + ]; + + let configured_local: HashSet = ["pkg-one".to_string(), "pkg-two".to_string()].into_iter().collect(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.copied, 2); + assert!(cache_dir.path().join("local/pkg-one/1.0.0").exists()); + assert!(cache_dir.path().join("local/pkg-two/2.0.0").exists()); + } + + #[test] + fn fail_on_name_mismatch() { + let src_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + + // Create package with different name in manifest + create_local_package(src_dir.path(), "actual-name", "1.0.0", None); + + let entries = vec![PackageEntry::Local { + name: "wrong-name".to_string(), + dir: src_dir.path().to_path_buf(), + }]; + + let configured_local: HashSet = ["wrong-name".to_string()].into_iter().collect(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.copied, 0); + assert_eq!(result.stats.failed, 1); + } + + #[test] + fn fail_on_missing_manifest() { + let src_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + + // Create directory without typst.toml + fs::create_dir_all(src_dir.path()).unwrap(); + fs::write(src_dir.path().join("lib.typ"), "// no manifest").unwrap(); + + let entries = vec![PackageEntry::Local { + name: "my-pkg".to_string(), + dir: src_dir.path().to_path_buf(), + }]; + + let configured_local: HashSet = ["my-pkg".to_string()].into_iter().collect(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.copied, 0); + assert_eq!(result.stats.failed, 1); + } + + #[test] + fn fail_on_nonexistent_directory() { + let cache_dir = TempDir::new().unwrap(); + + let entries = vec![PackageEntry::Local { + name: "my-pkg".to_string(), + dir: "/nonexistent/path/to/package".into(), + }]; + + let configured_local: HashSet = ["my-pkg".to_string()].into_iter().collect(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.copied, 0); + assert_eq!(result.stats.failed, 1); + } + + #[test] + fn preserves_subdirectories() { + let src_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + + create_local_package(src_dir.path(), "my-pkg", "1.0.0", None); + + // Add subdirectory with files + let sub = src_dir.path().join("src/utils"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("helper.typ"), "// helper").unwrap(); + + let entries = vec![PackageEntry::Local { + name: "my-pkg".to_string(), + dir: src_dir.path().to_path_buf(), + }]; + + let configured_local: HashSet = ["my-pkg".to_string()].into_iter().collect(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.copied, 1); + + let cached_helper = cache_dir + .path() + .join("local/my-pkg/1.0.0/src/utils/helper.typ"); + assert!(cached_helper.exists()); + } +} + +mod dependency_scanning { + use super::*; + + #[test] + fn find_imports_in_single_file() { + let dir = TempDir::new().unwrap(); + + let content = r#" +#import "@preview/cetz:0.4.1": canvas +#import "@preview/fletcher:0.5.3" + += Document +"#; + fs::write(dir.path().join("main.typ"), content).unwrap(); + + let imports = find_imports(dir.path()); + + assert_eq!(imports.len(), 2); + let names: Vec<_> = imports.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"cetz")); + assert!(names.contains(&"fletcher")); + } + + #[test] + fn find_imports_in_nested_files() { + let dir = TempDir::new().unwrap(); + + fs::write( + dir.path().join("main.typ"), + r#"#import "@preview/cetz:0.4.1""#, + ) + .unwrap(); + + let sub = dir.path().join("chapters"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("intro.typ"), r#"#import "@preview/fletcher:0.5.3""#).unwrap(); + + let imports = find_imports(dir.path()); + + assert_eq!(imports.len(), 2); + } + + #[test] + fn ignore_non_typ_files() { + let dir = TempDir::new().unwrap(); + + fs::write( + dir.path().join("main.typ"), + r#"#import "@preview/cetz:0.4.1""#, + ) + .unwrap(); + fs::write( + dir.path().join("notes.txt"), + r#"#import "@preview/ignored:1.0.0""#, + ) + .unwrap(); + + let imports = find_imports(dir.path()); + + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].name, "cetz"); + } + + #[test] + fn find_includes() { + let dir = TempDir::new().unwrap(); + + let content = r#"#include "@preview/template:1.0.0""#; + fs::write(dir.path().join("main.typ"), content).unwrap(); + + let imports = find_imports(dir.path()); + + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].name, "template"); + } + + #[test] + fn ignore_relative_imports() { + let dir = TempDir::new().unwrap(); + + let content = r#" +#import "@preview/cetz:0.4.1" +#import "utils.typ" +#import "../shared/common.typ" +"#; + fs::write(dir.path().join("main.typ"), content).unwrap(); + + let imports = find_imports(dir.path()); + + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].name, "cetz"); + } + + #[test] + fn empty_directory() { + let dir = TempDir::new().unwrap(); + let imports = find_imports(dir.path()); + assert!(imports.is_empty()); + } +} + +mod config_integration { + use super::*; + + #[test] + fn parse_and_cache_local_from_toml() { + let src_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + + create_local_package(src_dir.path(), "my-pkg", "1.0.0", None); + + let toml = format!( + r#" +destination = "{}" + +[local] +my-pkg = "{}" +"#, + cache_dir.path().display(), + src_dir.path().display() + ); + + let config = Config::parse(&toml).unwrap(); + let dest = config.destination.clone().unwrap(); + let configured_local: HashSet = config.local.keys().cloned().collect(); + let entries = config.into_entries(); + let result = gather_packages(&dest, entries, &[], &configured_local); + + assert_eq!(result.stats.copied, 1); + assert!(cache_dir.path().join("local/my-pkg/1.0.0").exists()); + } + + #[test] + fn empty_config_does_nothing() { + let cache_dir = TempDir::new().unwrap(); + + let toml = format!(r#"destination = "{}""#, cache_dir.path().display()); + let config = Config::parse(&toml).unwrap(); + let dest = config.destination.clone().unwrap(); + let configured_local: HashSet = config.local.keys().cloned().collect(); + let entries = config.into_entries(); + let result = gather_packages(&dest, entries, &[], &configured_local); + + assert_eq!(result.stats.downloaded, 0); + assert_eq!(result.stats.copied, 0); + assert_eq!(result.stats.skipped, 0); + assert_eq!(result.stats.failed, 0); + } + + #[test] + fn missing_destination_returns_none() { + let config = Config::parse("").unwrap(); + assert!(config.destination.is_none()); + } + + #[test] + fn parse_discover_field() { + let toml = r#" +destination = "/cache" +discover = "/path/to/templates" +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!( + config.discover, + vec![std::path::PathBuf::from("/path/to/templates")] + ); + } + + #[test] + fn parse_discover_array() { + let toml = r#" +destination = "/cache" +discover = ["template.typ", "typst-show.typ"] +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!( + config.discover, + vec![ + std::path::PathBuf::from("template.typ"), + std::path::PathBuf::from("typst-show.typ"), + ] + ); + } +} + +mod unconfigured_local { + use super::*; + + #[test] + fn detects_unconfigured_local_imports() { + let cache_dir = TempDir::new().unwrap(); + let discover_dir = TempDir::new().unwrap(); + + // Create a .typ file that imports @local/my-pkg + let content = r#"#import "@local/my-pkg:1.0.0""#; + fs::write(discover_dir.path().join("template.typ"), content).unwrap(); + + // Don't configure my-pkg in the local section + let configured_local: HashSet = HashSet::new(); + let discover = vec![discover_dir.path().to_path_buf()]; + + let result = gather_packages(cache_dir.path(), vec![], &discover, &configured_local); + + // Should have one unconfigured local + assert_eq!(result.unconfigured_local.len(), 1); + assert_eq!(result.unconfigured_local[0].0, "my-pkg"); + } + + #[test] + fn configured_local_not_reported() { + let cache_dir = TempDir::new().unwrap(); + let discover_dir = TempDir::new().unwrap(); + + // Create a .typ file that imports @local/my-pkg + let content = r#"#import "@local/my-pkg:1.0.0""#; + fs::write(discover_dir.path().join("template.typ"), content).unwrap(); + + // Configure my-pkg (even though we don't actually copy it) + let configured_local: HashSet = ["my-pkg".to_string()].into_iter().collect(); + let discover = vec![discover_dir.path().to_path_buf()]; + + let result = gather_packages(cache_dir.path(), vec![], &discover, &configured_local); + + // Should have no unconfigured local + assert!(result.unconfigured_local.is_empty()); + } +} + +/// Tests that require network access. +/// Run with: cargo test -- --ignored +mod network { + use super::*; + + #[test] + #[ignore = "requires network access"] + fn download_preview_package() { + let cache_dir = TempDir::new().unwrap(); + + let entries = vec![PackageEntry::Preview { + name: "example".to_string(), + version: "0.1.0".to_string(), + }]; + + let configured_local = HashSet::new(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.downloaded, 1); + assert_eq!(result.stats.failed, 0); + + let cached = cache_dir.path().join("preview/example/0.1.0"); + assert!(cached.exists()); + assert!(cached.join("typst.toml").exists()); + } + + #[test] + #[ignore = "requires network access"] + fn download_package_with_dependencies() { + let cache_dir = TempDir::new().unwrap(); + + // cetz has dependencies that should be auto-downloaded + let entries = vec![PackageEntry::Preview { + name: "cetz".to_string(), + version: "0.3.4".to_string(), + }]; + + let configured_local = HashSet::new(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + // Should download cetz plus its dependencies + assert!(result.stats.downloaded >= 1); + assert_eq!(result.stats.failed, 0); + } + + #[test] + #[ignore = "requires network access"] + fn skip_already_cached() { + let cache_dir = TempDir::new().unwrap(); + + let entries = vec![PackageEntry::Preview { + name: "example".to_string(), + version: "0.1.0".to_string(), + }]; + + let configured_local = HashSet::new(); + + // First download + let result1 = gather_packages(cache_dir.path(), entries.clone(), &[], &configured_local); + assert_eq!(result1.stats.downloaded, 1); + + // Second run should skip + let result2 = gather_packages(cache_dir.path(), entries, &[], &configured_local); + assert_eq!(result2.stats.downloaded, 0); + assert_eq!(result2.stats.skipped, 1); + } + + #[test] + #[ignore = "requires network access"] + fn fail_on_nonexistent_package() { + let cache_dir = TempDir::new().unwrap(); + + let entries = vec![PackageEntry::Preview { + name: "this-package-does-not-exist-12345".to_string(), + version: "0.0.0".to_string(), + }]; + + let configured_local = HashSet::new(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.downloaded, 0); + assert_eq!(result.stats.failed, 1); + } + + #[test] + #[ignore = "requires network access"] + fn local_package_triggers_preview_deps() { + let src_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + + // Create local package that imports a preview package + let content = r#" +#import "@preview/example:0.1.0" + +#let my-func() = [] +"#; + create_local_package(src_dir.path(), "my-pkg", "1.0.0", Some(content)); + + let entries = vec![PackageEntry::Local { + name: "my-pkg".to_string(), + dir: src_dir.path().to_path_buf(), + }]; + + let configured_local: HashSet = ["my-pkg".to_string()].into_iter().collect(); + let result = gather_packages(cache_dir.path(), entries, &[], &configured_local); + + assert_eq!(result.stats.copied, 1); + assert!(result.stats.downloaded >= 1); // Should have downloaded example + + assert!(cache_dir.path().join("local/my-pkg/1.0.0").exists()); + assert!(cache_dir.path().join("preview/example/0.1.0").exists()); + } +} diff --git a/src/command/call/cmd.ts b/src/command/call/cmd.ts index c55468c3032..018382bdb6d 100644 --- a/src/command/call/cmd.ts +++ b/src/command/call/cmd.ts @@ -1,6 +1,7 @@ import { Command } from "cliffy/command/mod.ts"; import { engineCommand } from "./engine-cmd.ts"; import { buildTsExtensionCommand } from "./build-ts-extension/cmd.ts"; +import { typstGatherCommand } from "./typst-gather/cmd.ts"; export const callCommand = new Command() .name("call") @@ -12,4 +13,5 @@ export const callCommand = new Command() Deno.exit(1); }) .command("engine", engineCommand) - .command("build-ts-extension", buildTsExtensionCommand); + .command("build-ts-extension", buildTsExtensionCommand) + .command("typst-gather", typstGatherCommand); diff --git a/src/command/call/typst-gather/cmd.ts b/src/command/call/typst-gather/cmd.ts new file mode 100644 index 00000000000..63a19a2d4d2 --- /dev/null +++ b/src/command/call/typst-gather/cmd.ts @@ -0,0 +1,552 @@ +/* + * cmd.ts + * + * Copyright (C) 2025 Posit Software, PBC + */ + +import { Command } from "cliffy/command/mod.ts"; +import { info } from "../../../deno_ral/log.ts"; + +import { architectureToolsPath } from "../../../core/resources.ts"; +import { execProcess } from "../../../core/process.ts"; +import { dirname, join, relative } from "../../../deno_ral/path.ts"; +import { existsSync } from "../../../deno_ral/fs.ts"; +import { expandGlobSync } from "../../../core/deno/expand-glob.ts"; +import { readYaml } from "../../../core/yaml.ts"; + +interface ExtensionYml { + contributes?: { + formats?: { + typst?: { + template?: string; + "template-partials"?: string[]; + }; + }; + }; +} + +interface TypstGatherConfig { + configFile?: string; // Path to config file if one was found + rootdir?: string; + destination: string; + discover: string[]; +} + +async function findExtensionDir(): Promise { + const cwd = Deno.cwd(); + + // Check if we're in an extension directory (has _extension.yml) + if (existsSync(join(cwd, "_extension.yml"))) { + return cwd; + } + + // Check if there's an _extensions directory with a single extension + const extensionsDir = join(cwd, "_extensions"); + if (existsSync(extensionsDir)) { + const extensionDirs: string[] = []; + for (const entry of expandGlobSync("_extensions/**/_extension.yml")) { + extensionDirs.push(dirname(entry.path)); + } + + if (extensionDirs.length === 1) { + return extensionDirs[0]; + } else if (extensionDirs.length > 1) { + console.error("Multiple extension directories found.\n"); + console.error( + "Run this command from within a specific extension directory,", + ); + console.error( + "or create a typst-gather.toml to specify the configuration.", + ); + return null; + } + } + + return null; +} + +function extractTypstFiles(extensionDir: string): string[] { + const extensionYmlPath = join(extensionDir, "_extension.yml"); + + if (!existsSync(extensionYmlPath)) { + return []; + } + + try { + const yml = readYaml(extensionYmlPath) as ExtensionYml; + const typstConfig = yml?.contributes?.formats?.typst; + + if (!typstConfig) { + return []; + } + + const files: string[] = []; + + // Add template if specified + if (typstConfig.template) { + files.push(join(extensionDir, typstConfig.template)); + } + + // Add template-partials if specified + if (typstConfig["template-partials"]) { + for (const partial of typstConfig["template-partials"]) { + files.push(join(extensionDir, partial)); + } + } + + return files; + } catch { + return []; + } +} + +async function resolveConfig( + extensionDir: string | null, +): Promise { + const cwd = Deno.cwd(); + + // First, check for typst-gather.toml in current directory + const configPath = join(cwd, "typst-gather.toml"); + if (existsSync(configPath)) { + info(`Using config: ${configPath}`); + // Return the config file path - rust will parse it directly + // We still parse minimally to validate and show info + const content = Deno.readTextFileSync(configPath); + const config = parseSimpleToml(content); + config.configFile = configPath; + return config; + } + + // No config file - try to auto-detect from _extension.yml + if (!extensionDir) { + console.error( + "No typst-gather.toml found and no extension directory detected.\n", + ); + console.error("Either:"); + console.error(" 1. Create a typst-gather.toml file, or"); + console.error( + " 2. Run from within an extension directory with _extension.yml", + ); + return null; + } + + const typstFiles = extractTypstFiles(extensionDir); + + if (typstFiles.length === 0) { + console.error("No Typst files found in _extension.yml.\n"); + console.error( + "The extension must define 'template' or 'template-partials' under contributes.formats.typst", + ); + return null; + } + + // Default destination is 'typst/packages' directory in extension folder + const destination = join(extensionDir, "typst/packages"); + + // Show paths relative to cwd for cleaner output + const relDest = relative(cwd, destination); + const relFiles = typstFiles.map((f) => relative(cwd, f)); + + info(`Auto-detected from _extension.yml:`); + info(` Destination: ${relDest}`); + info(` Files to scan: ${relFiles.join(", ")}`); + + return { + destination, + discover: typstFiles, + }; +} + +function parseSimpleToml(content: string): TypstGatherConfig { + const lines = content.split("\n"); + let rootdir: string | undefined; + let destination = ""; + const discover: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // Parse rootdir + const rootdirMatch = trimmed.match(/^rootdir\s*=\s*"([^"]+)"/); + if (rootdirMatch) { + rootdir = rootdirMatch[1]; + continue; + } + + // Parse destination + const destMatch = trimmed.match(/^destination\s*=\s*"([^"]+)"/); + if (destMatch) { + destination = destMatch[1]; + continue; + } + + // Parse discover as string + const discoverStrMatch = trimmed.match(/^discover\s*=\s*"([^"]+)"/); + if (discoverStrMatch) { + discover.push(discoverStrMatch[1]); + continue; + } + + // Parse discover as array (simple single-line parsing) + const discoverArrMatch = trimmed.match(/^discover\s*=\s*\[([^\]]+)\]/); + if (discoverArrMatch) { + const items = discoverArrMatch[1].split(","); + for (const item of items) { + const match = item.trim().match(/"([^"]+)"/); + if (match) { + discover.push(match[1]); + } + } + } + } + + return { rootdir, destination, discover }; +} + +interface DiscoveredImport { + name: string; + version: string; + sourceFile: string; +} + +interface DiscoveryResult { + preview: DiscoveredImport[]; + local: DiscoveredImport[]; + scannedFiles: string[]; +} + +function discoverImportsFromFiles(files: string[]): DiscoveryResult { + const result: DiscoveryResult = { + preview: [], + local: [], + scannedFiles: [], + }; + + // Regex to match @namespace/name:version imports + // Note: #include is for files, not packages, so we only match #import + const importRegex = /#import\s+"@(\w+)\/([^:]+):([^"]+)"/g; + + for (const file of files) { + if (!existsSync(file)) continue; + if (!file.endsWith(".typ")) continue; + + const filename = file.split("/").pop() || file; + result.scannedFiles.push(filename); + + try { + const content = Deno.readTextFileSync(file); + let match; + while ((match = importRegex.exec(content)) !== null) { + const [, namespace, name, version] = match; + const entry = { name, version, sourceFile: filename }; + + if (namespace === "preview") { + result.preview.push(entry); + } else if (namespace === "local") { + result.local.push(entry); + } + } + } catch { + // Skip files that can't be read + } + } + + return result; +} + +function generateConfigContent( + discovery: DiscoveryResult, + rootdir?: string, +): string { + const lines: string[] = []; + + lines.push("# typst-gather configuration"); + lines.push("# Run: quarto call typst-gather"); + lines.push(""); + + if (rootdir) { + lines.push(`rootdir = "${rootdir}"`); + } + lines.push('destination = "typst/packages"'); + lines.push(""); + + // Discover section + if (discovery.scannedFiles.length > 0) { + if (discovery.scannedFiles.length === 1) { + lines.push(`discover = "${discovery.scannedFiles[0]}"`); + } else { + const files = discovery.scannedFiles.map((f) => `"${f}"`).join(", "); + lines.push(`discover = [${files}]`); + } + } else { + lines.push('# discover = "template.typ" # Add your .typ files here'); + } + + lines.push(""); + + // Preview section (commented out - packages will be auto-discovered) + lines.push("# Preview packages are auto-discovered from imports."); + lines.push("# Uncomment to pin specific versions:"); + lines.push("# [preview]"); + if (discovery.preview.length > 0) { + // Deduplicate + const seen = new Set(); + for (const { name, version } of discovery.preview) { + if (!seen.has(name)) { + seen.add(name); + lines.push(`# ${name} = "${version}"`); + } + } + } else { + lines.push('# cetz = "0.4.1"'); + } + + lines.push(""); + + // Local section + lines.push( + "# Local packages (@local namespace) must be configured manually.", + ); + if (discovery.local.length > 0) { + lines.push("# Found @local imports:"); + const seen = new Set(); + for (const { name, version, sourceFile } of discovery.local) { + if (!seen.has(name)) { + seen.add(name); + lines.push(`# @local/${name}:${version} (in ${sourceFile})`); + } + } + lines.push("[local]"); + seen.clear(); + for (const { name } of discovery.local) { + if (!seen.has(name)) { + seen.add(name); + lines.push(`${name} = "/path/to/${name}" # TODO: set correct path`); + } + } + } else { + lines.push("# [local]"); + lines.push('# my-pkg = "/path/to/my-pkg"'); + } + + lines.push(""); + return lines.join("\n"); +} + +async function initConfig(): Promise { + const configFile = join(Deno.cwd(), "typst-gather.toml"); + + // Check if config already exists + if (existsSync(configFile)) { + console.error("typst-gather.toml already exists"); + console.error("Remove it first or edit it manually."); + Deno.exit(1); + } + + // Find typst files via extension directory structure + const extensionDir = await findExtensionDir(); + + if (!extensionDir) { + console.error("No extension directory found."); + console.error( + "Run this command from a directory containing _extension.yml or _extensions/", + ); + Deno.exit(1); + } + + const typFiles = extractTypstFiles(extensionDir); + + if (typFiles.length === 0) { + info("Warning: No .typ files found in _extension.yml."); + info( + "Edit the generated typst-gather.toml to configure local or pinned dependencies.", + ); + } else { + info(`Found extension: ${extensionDir}`); + } + + // Discover imports from the files + const discovery = discoverImportsFromFiles(typFiles); + + // Calculate relative path from cwd to extension dir for rootdir + const rootdir = relative(Deno.cwd(), extensionDir); + + // Generate config content + const configContent = generateConfigContent(discovery, rootdir); + + // Write config file + try { + Deno.writeTextFileSync(configFile, configContent); + } catch (e) { + console.error(`Error writing typst-gather.toml: ${e}`); + Deno.exit(1); + } + + info("Created typst-gather.toml"); + if (discovery.scannedFiles.length > 0) { + info(` Scanned: ${discovery.scannedFiles.join(", ")}`); + } + if (discovery.preview.length > 0) { + info(` Found ${discovery.preview.length} @preview import(s)`); + } + if (discovery.local.length > 0) { + info( + ` Found ${discovery.local.length} @local import(s) - configure paths in [local] section`, + ); + } + + info(""); + info("Next steps:"); + info(" 1. Review and edit typst-gather.toml"); + if (discovery.local.length > 0) { + info(" 2. Add paths for @local packages in [local] section"); + } + info(" 3. Run: quarto call typst-gather"); +} + +export const typstGatherCommand = new Command() + .name("typst-gather") + .description( + "Gather Typst packages for a format extension.\n\n" + + "This command scans Typst files for @preview imports and downloads " + + "the packages to a local directory for offline use.\n\n" + + "Configuration is determined by:\n" + + " 1. typst-gather.toml in current directory (if present)\n" + + " 2. Auto-detection from _extension.yml (template and template-partials)", + ) + .option( + "--init-config", + "Generate a starter typst-gather.toml in current directory", + ) + .action(async (options: { initConfig?: boolean }) => { + // Handle --init-config + if (options.initConfig) { + await initConfig(); + return; + } + try { + // Find extension directory + const extensionDir = await findExtensionDir(); + + // Resolve configuration + const config = await resolveConfig(extensionDir); + if (!config) { + Deno.exit(1); + } + + if (!config.destination) { + console.error("No destination specified in configuration."); + Deno.exit(1); + } + + if (config.discover.length === 0) { + console.error("No files to discover imports from."); + Deno.exit(1); + } + + // Find typst-gather binary + // First try architecture-specific path, then fall back to PATH + let typstGatherBinary: string; + + const archPath = architectureToolsPath("typst-gather"); + if (existsSync(archPath)) { + typstGatherBinary = archPath; + } else { + // Try to find in PATH or use development location + const quartoRoot = Deno.env.get("QUARTO_ROOT"); + if (quartoRoot) { + const devPath = join( + quartoRoot, + "package/typst-gather/target/release/typst-gather", + ); + if (existsSync(devPath)) { + typstGatherBinary = devPath; + } else { + console.error( + `typst-gather binary not found.\n` + + `Build it with: cd package/typst-gather && cargo build --release`, + ); + Deno.exit(1); + } + } else { + console.error("typst-gather binary not found."); + Deno.exit(1); + } + } + + // Determine config file to use + let configFileToUse: string; + let tempConfig: string | null = null; + + if (config.configFile) { + // Use existing config file directly - rust will parse [local], [preview], etc. + configFileToUse = config.configFile; + } else { + // Create a temporary TOML config file for auto-detected config + tempConfig = Deno.makeTempFileSync({ suffix: ".toml" }); + const discoverArray = config.discover.map((p) => `"${p}"`).join(", "); + let tomlContent = ""; + if (config.rootdir) { + tomlContent += `rootdir = "${config.rootdir}"\n`; + } + tomlContent += `destination = "${config.destination}"\n`; + tomlContent += `discover = [${discoverArray}]\n`; + Deno.writeTextFileSync(tempConfig, tomlContent); + configFileToUse = tempConfig; + } + + info(`Running typst-gather...`); + + // Run typst-gather + const result = await execProcess({ + cmd: typstGatherBinary, + args: [configFileToUse], + cwd: Deno.cwd(), + }); + + // Clean up temp file if we created one + if (tempConfig) { + try { + Deno.removeSync(tempConfig); + } catch { + // Ignore cleanup errors + } + } + + if (!result.success) { + // Print any output from the tool + if (result.stdout) { + console.log(result.stdout); + } + if (result.stderr) { + console.error(result.stderr); + } + + // Check for @local imports not configured error and suggest --init-config + // Only suggest if no config file was found + const output = (result.stdout || "") + (result.stderr || ""); + if ( + output.includes("@local imports not configured") && !config.configFile + ) { + console.error(""); + console.error( + "Tip: Run 'quarto call typst-gather --init-config' to generate a config file", + ); + console.error( + " with placeholders for your @local package paths.", + ); + } + + Deno.exit(1); + } + + info("Done!"); + } catch (e) { + if (e instanceof Error) { + console.error(e.message); + } else { + console.error(String(e)); + } + Deno.exit(1); + } + }); diff --git a/src/command/dev-call/cmd.ts b/src/command/dev-call/cmd.ts index b9a07f44b9b..4a2b892915c 100644 --- a/src/command/dev-call/cmd.ts +++ b/src/command/dev-call/cmd.ts @@ -6,6 +6,7 @@ import { validateYamlCommand } from "./validate-yaml/cmd.ts"; import { showAstTraceCommand } from "./show-ast-trace/cmd.ts"; import { makeAstDiagramCommand } from "./make-ast-diagram/cmd.ts"; import { pullGitSubtreeCommand } from "./pull-git-subtree/cmd.ts"; +import { typstGatherCommand } from "./typst-gather/cmd.ts"; type CommandOptionInfo = { name: string; @@ -77,4 +78,5 @@ export const devCallCommand = new Command() .command("build-artifacts", buildJsCommand) .command("show-ast-trace", showAstTraceCommand) .command("make-ast-diagram", makeAstDiagramCommand) - .command("pull-git-subtree", pullGitSubtreeCommand); + .command("pull-git-subtree", pullGitSubtreeCommand) + .command("typst-gather", typstGatherCommand); diff --git a/src/command/dev-call/typst-gather/cmd.ts b/src/command/dev-call/typst-gather/cmd.ts new file mode 100644 index 00000000000..f3b532d8106 --- /dev/null +++ b/src/command/dev-call/typst-gather/cmd.ts @@ -0,0 +1,72 @@ +/* + * cmd.ts + * + * Copyright (C) 2025 Posit Software, PBC + */ + +import { Command } from "cliffy/command/mod.ts"; +import { error, info } from "../../../deno_ral/log.ts"; +import { join } from "../../../deno_ral/path.ts"; + +export const typstGatherCommand = new Command() + .name("typst-gather") + .hidden() + .description( + "Gather Typst packages for offline/hermetic builds.\n\n" + + "This command runs the typst-gather tool to download @preview packages " + + "and copy @local packages to a local directory for use during Quarto builds.", + ) + .action(async () => { + // Get quarto root directory + const quartoRoot = Deno.env.get("QUARTO_ROOT"); + if (!quartoRoot) { + error( + "QUARTO_ROOT environment variable not set. This command requires a development version of Quarto.", + ); + Deno.exit(1); + } + + // Path to the TOML config file (relative to this source file's location in the repo) + const tomlPath = join( + quartoRoot, + "src/command/dev-call/typst-gather/typst-gather.toml", + ); + + // Path to the typst-gather binary + const typstGatherBinary = join( + quartoRoot, + "package/typst-gather/target/release/typst-gather", + ); + + // Check if binary exists + try { + await Deno.stat(typstGatherBinary); + } catch { + error( + `typst-gather binary not found at ${typstGatherBinary}\n` + + "Build it with: cd package/typst-gather && cargo build --release", + ); + Deno.exit(1); + } + + info(`Quarto root: ${quartoRoot}`); + info(`Config: ${tomlPath}`); + info(`Running typst-gather...`); + + // Run typst-gather from the quarto root directory + const command = new Deno.Command(typstGatherBinary, { + args: [tomlPath], + cwd: quartoRoot, + stdout: "inherit", + stderr: "inherit", + }); + + const result = await command.output(); + + if (!result.success) { + error(`typst-gather failed with exit code ${result.code}`); + Deno.exit(result.code); + } + + info("Done!"); + }); diff --git a/src/command/dev-call/typst-gather/typst-gather.toml b/src/command/dev-call/typst-gather/typst-gather.toml new file mode 100644 index 00000000000..5f0945b7eb2 --- /dev/null +++ b/src/command/dev-call/typst-gather/typst-gather.toml @@ -0,0 +1,2 @@ +destination = "src/resources/formats/typst/packages" +discover = ["src/resources/formats/typst/pandoc/quarto"] diff --git a/src/command/render/output-typst.ts b/src/command/render/output-typst.ts index 83a5b3f9cac..7f11880185b 100644 --- a/src/command/render/output-typst.ts +++ b/src/command/render/output-typst.ts @@ -5,7 +5,18 @@ */ import { dirname, join, normalize, relative } from "../../deno_ral/path.ts"; -import { ensureDirSync, safeRemoveSync } from "../../deno_ral/fs.ts"; +import { + copySync, + ensureDirSync, + existsSync, + safeRemoveSync, +} from "../../deno_ral/fs.ts"; +import { + inputExtensionDirs, + readExtensions, +} from "../../extension/extension.ts"; +import { projectScratchPath } from "../../project/project-scratch.ts"; +import { resourcePath } from "../../core/resources.ts"; import { kFontPaths, @@ -28,6 +39,66 @@ import { import { asArray } from "../../core/array.ts"; import { ProjectContext } from "../../project/types.ts"; +// Stage typst packages to .quarto/typst-packages/ +// First stages built-in packages, then extension packages (which can override) +async function stageTypstPackages( + input: string, + projectDir?: string, +): Promise { + if (!projectDir) { + return undefined; + } + + const packageSources: string[] = []; + + // 1. Add built-in packages from quarto resources + const builtinPackages = resourcePath("formats/typst/packages"); + if (existsSync(builtinPackages)) { + packageSources.push(builtinPackages); + } + + // 2. Add packages from extensions (can override built-in) + const extensionDirs = inputExtensionDirs(input, projectDir); + for (const extDir of extensionDirs) { + const extensions = await readExtensions(extDir); + for (const ext of extensions) { + const packagesDir = join(ext.path, "typst/packages"); + if (existsSync(packagesDir)) { + packageSources.push(packagesDir); + } + } + } + + if (packageSources.length === 0) { + return undefined; + } + + // Stage to .quarto/typst/packages/ + const cacheDir = projectScratchPath(projectDir, "typst/packages"); + + // Copy contents of each source directory (merging namespaces like "preview", "local") + for (const source of packageSources) { + for (const entry of Deno.readDirSync(source)) { + const srcPath = join(source, entry.name); + const destPath = join(cacheDir, entry.name); + if (!existsSync(destPath)) { + copySync(srcPath, destPath); + } else if (entry.isDirectory) { + // Merge directory contents (e.g., merge packages within "preview" namespace) + for (const subEntry of Deno.readDirSync(srcPath)) { + const subSrcPath = join(srcPath, subEntry.name); + const subDestPath = join(destPath, subEntry.name); + if (!existsSync(subDestPath)) { + copySync(subSrcPath, subDestPath); + } + } + } + } + } + + return cacheDir; +} + export function useTypstPdfOutputRecipe( format: Format, ) { @@ -58,7 +129,7 @@ export function typstPdfOutputRecipe( // output to the user's requested destination const complete = async () => { // input file is pandoc's output - const input = join(inputDir, output); + const typstInput = join(inputDir, output); // run typst await validateRequiredTypstVersion(); @@ -69,9 +140,15 @@ export function typstPdfOutputRecipe( }; if (project?.dir) { typstOptions.rootDir = project.dir; + + // Stage extension typst packages + const packagePath = await stageTypstPackages(input, project.dir); + if (packagePath) { + typstOptions.packagePath = packagePath; + } } const result = await typstCompile( - input, + typstInput, pdfOutput, typstOptions, ); @@ -81,7 +158,7 @@ export function typstPdfOutputRecipe( // keep typ if requested if (!format.render[kKeepTyp]) { - safeRemoveSync(input); + safeRemoveSync(typstInput); } // copy (or write for stdout) compiled pdf to final output location @@ -101,9 +178,9 @@ export function typstPdfOutputRecipe( // final output needs to either absolute or input dir relative // (however it may be working dir relative when it is passed in) - return normalizeOutputPath(input, finalOutput); + return normalizeOutputPath(typstInput, finalOutput); } else { - return normalizeOutputPath(input, pdfOutput); + return normalizeOutputPath(typstInput, pdfOutput); } }; diff --git a/src/command/render/pandoc.ts b/src/command/render/pandoc.ts index 001113c5311..35b9ac1f919 100644 --- a/src/command/render/pandoc.ts +++ b/src/command/render/pandoc.ts @@ -66,6 +66,7 @@ import { projectIsWebsite, } from "../../project/project-shared.ts"; import { deleteCrossrefMetadata } from "../../project/project-crossrefs.ts"; +import { migrateProjectScratchPath } from "../../project/project-scratch.ts"; import { getPandocArg, @@ -1586,7 +1587,11 @@ async function resolveExtras( } } if (ttf_urls.length || woff_urls.length) { - const font_cache = join(brand!.projectDir, ".quarto", "typst-font-cache"); + const font_cache = migrateProjectScratchPath( + brand!.projectDir, + "typst-font-cache", + "typst/fonts", + ); const url_to_path = (url: string) => url.replace(/^https?:\/\//, ""); const cached = async (url: string) => { const path = url_to_path(url); diff --git a/src/core/typst.ts b/src/core/typst.ts index 09e95cab994..42d9ea97876 100644 --- a/src/core/typst.ts +++ b/src/core/typst.ts @@ -5,7 +5,8 @@ */ import { error, info } from "../deno_ral/log.ts"; -import { basename } from "../deno_ral/path.ts"; +import { basename, join } from "../deno_ral/path.ts"; +import { existsSync } from "../deno_ral/fs.ts"; import * as colors from "fmt/colors"; import { satisfies } from "semver/mod.ts"; @@ -39,6 +40,7 @@ export type TypstCompileOptions = { quiet?: boolean; fontPaths?: string[]; rootDir?: string; + packagePath?: string; }; export async function typstCompile( @@ -58,6 +60,18 @@ export async function typstCompile( if (options.rootDir) { cmd.push("--root", options.rootDir); } + if (options.packagePath) { + // Only set --package-path if local/ subdirectory exists (for @local packages) + const localDir = join(options.packagePath, "local"); + if (existsSync(localDir)) { + cmd.push("--package-path", options.packagePath); + } + // Only set --package-cache-path if preview/ subdirectory exists (for @preview packages) + const previewDir = join(options.packagePath, "preview"); + if (existsSync(previewDir)) { + cmd.push("--package-cache-path", options.packagePath); + } + } cmd.push( input, ...fontPathsArgs(fontPaths), diff --git a/src/import_map.json b/src/import_map.json index 4e04db1a810..2843c63cd78 100644 --- a/src/import_map.json +++ b/src/import_map.json @@ -57,6 +57,8 @@ "puppeteer": "https://deno.land/x/puppeteer@9.0.2/mod.ts", + "pdfjs-dist": "npm:pdfjs-dist@4.9.155", + "https://deno.land/std@0.196.0/console/unicode_width.ts": "https://deno.land/std@0.224.0/console/unicode_width.ts", "https://deno.land/std@0.161.0/": "https://deno.land/std@0.217.0/", "https://deno.land/std@0.101.0/": "https://deno.land/std@0.217.0/", diff --git a/src/project/project-scratch.ts b/src/project/project-scratch.ts index b85ebc7cfc4..508757b0f32 100644 --- a/src/project/project-scratch.ts +++ b/src/project/project-scratch.ts @@ -5,8 +5,9 @@ */ import { dirname, join } from "../deno_ral/path.ts"; -import { ensureDirSync } from "../deno_ral/fs.ts"; +import { ensureDirSync, existsSync } from "../deno_ral/fs.ts"; import { normalizePath } from "../core/path.ts"; +import { warning } from "../deno_ral/log.ts"; export const kQuartoScratch = ".quarto"; @@ -21,3 +22,29 @@ export function projectScratchPath(dir: string, path = "") { return normalizePath(scratchDir); } } + +// Migrate a scratch path from an old location to a new location. +// If the old path exists and the new path doesn't, moves the old to new. +// Returns the new path (via projectScratchPath which ensures it exists). +export function migrateProjectScratchPath( + dir: string, + oldSubpath: string, + newSubpath: string, +): string { + const scratchDir = join(dir, kQuartoScratch); + const oldPath = join(scratchDir, oldSubpath); + const newPath = join(scratchDir, newSubpath); + + if (existsSync(oldPath) && !existsSync(newPath)) { + // Ensure parent directory of new path exists + ensureDirSync(dirname(newPath)); + try { + Deno.renameSync(oldPath, newPath); + } catch (e) { + // Migration failed - not fatal, cache will be rebuilt + warning(`Failed to migrate ${oldSubpath} to ${newSubpath}: ${e}`); + } + } + + return projectScratchPath(dir, newSubpath); +} diff --git a/src/resources/filters/customnodes/floatreftarget.lua b/src/resources/filters/customnodes/floatreftarget.lua index 0bb2678a596..e4db0b6ffc0 100644 --- a/src/resources/filters/customnodes/floatreftarget.lua +++ b/src/resources/filters/customnodes/floatreftarget.lua @@ -964,6 +964,7 @@ end) _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isTypstOutput() end, function(float) + -- Get crossref info first (needed for both margin and regular figures) local ref = ref_type_from_float(float) local info = crossref.categories.by_ref_type[ref] if info == nil then @@ -974,6 +975,85 @@ end, function(float) end local kind = "quarto-float-" .. ref local supplement = titleString(ref, info.name) + + -- Check if this is a margin figure (has .column-margin or .aside class) + if hasMarginColumn(float) then + -- Use marginalia's notefigure for margin placement + local content = quarto.utils.as_blocks(float.content or {}) + + -- Get optional attributes + local shift = float.attributes and float.attributes["shift"] or "auto" + local alignment = float.attributes and float.attributes["alignment"] or "baseline" + local dy = float.attributes and float.attributes["dy"] or "0pt" + + -- Get caption location (tables default to top, figures to bottom) + local caption_location = cap_location(float) + if caption_location ~= "top" and caption_location ~= "bottom" then + caption_location = "bottom" + end + + return make_typst_margin_figure { + content = content, + caption = float.caption_long, + caption_location = caption_location, + identifier = float.identifier, + shift = shift, + alignment = alignment, + dy = dy, + kind = kind, + supplement = supplement + } + end + + -- Check for margin caption (figure in main column, caption in margin) + if hasMarginCaption(float) then + local content = quarto.utils.as_blocks(float.content or {}) + -- Margin captions align with top of content (consistent with HTML visual behavior) + local alignment = "top" + + return make_typst_margin_caption_figure { + content = content, + caption = float.caption_long, + identifier = float.identifier, + kind = kind, + supplement = supplement, + alignment = alignment, + } + end + + -- Check for full-width classes (column-page-right, column-page, column-screen, etc.) + -- Note: For cell outputs, columns.lua wraps the cell-output-display div in wideblock. + -- For fenced divs, the FloatRefTarget has the class and needs to wrap itself. + local wideblock_side = getWideblockSide(float.classes) + if wideblock_side then + local content = quarto.utils.as_blocks(float.content or {}) + -- Listings should not be centered inside the figure + if ref == "lst" then + content:insert(1, pandoc.RawBlock("typst", "#set align(left)")) + end + local caption_location = cap_location(float) + if caption_location ~= "top" and caption_location ~= "bottom" then + caption_location = "bottom" + end + + -- Render standard figure first + local figure_blocks = make_typst_figure { + content = content, + caption_location = caption_location, + caption = float.caption_long, + kind = kind, + supplement = supplement, + numbering = info.numbering, + identifier = float.identifier + } + + -- Wrap in wideblock + return make_typst_wideblock { + content = figure_blocks, + side = wideblock_side, + } + end + -- FIXME: custom numbering doesn't work yet -- local numbering = "" -- if float.parent_id then @@ -984,8 +1064,12 @@ end, function(float) local content = quarto.utils.as_blocks(float.content or {}) local caption_location = cap_location(float) - if (caption_location ~= "top" and caption_location ~= "bottom") then - -- warn this is not supported and default to bottom + if caption_location == "margin" then + -- Margin captions should have been caught by hasMarginCaption check above. + -- If we reach here, margin-layout may not be active. Fall back to bottom. + caption_location = "bottom" + elseif caption_location ~= "top" and caption_location ~= "bottom" then + -- Unknown caption location, warn and default to bottom warn("Typst does not support this caption location: " .. caption_location .. ". Defaulting to bottom for '" .. float.identifier .. "'.") caption_location = "bottom" end diff --git a/src/resources/filters/layout/columns-preprocess.lua b/src/resources/filters/layout/columns-preprocess.lua index 9d959bd1e63..3abb6e5a14c 100644 --- a/src/resources/filters/layout/columns-preprocess.lua +++ b/src/resources/filters/layout/columns-preprocess.lua @@ -1,12 +1,24 @@ -- columns-preprocess.lua -- Copyright (C) 2021-2022 Posit Software, PBC -function columns_preprocess() +function columns_preprocess() return { FloatRefTarget = function(float) if float.parent_id ~= nil then return nil end + -- Check for margin figure placement (.column-margin or .aside class) + if hasMarginColumn(float) then + noteHasColumns() + end + -- Check for full-width classes (column-page-*, column-screen-*) + if getWideblockSide(float.classes) then + noteHasColumns() + end + -- Check for margin caption class (added directly to element) + if hasMarginCaption(float) then + noteHasColumns() + end local location = cap_location(float) if location == 'margin' then float.classes:insert('margin-caption') diff --git a/src/resources/filters/layout/columns.lua b/src/resources/filters/layout/columns.lua index 77e47aaa58b..5f302d15753 100644 --- a/src/resources/filters/layout/columns.lua +++ b/src/resources/filters/layout/columns.lua @@ -74,20 +74,98 @@ local function def_columns() -- for html output that isn't reveal... if _quarto.format.isHtmlOutput() and not _quarto.format.isHtmlSlideOutput() then - + -- For HTML output, note that any div marked an aside should - -- be marked a column-margin element (so that it is processed - -- by post processors). + -- be marked a column-margin element (so that it is processed + -- by post processors). -- For example: https://github.com/quarto-dev/quarto-cli/issues/2701 if el.classes and tcontains(el.classes, 'aside') then noteHasColumns() - el.classes = el.classes:filter(function(attr) + el.classes = el.classes:filter(function(attr) return attr ~= "aside" end) tappend(el.classes, {'column-margin', "margin-aside"}) return el end - + + elseif _quarto.format.isTypstOutput() then + -- For Typst output, detect column classes to trigger margin layout setup + -- Actual margin note rendering is handled in quarto-post/typst.lua + if hasMarginColumn(el) or hasColumnClasses(el) then + noteHasColumns() + end + -- Convert aside class to column-margin for consistency + if el.classes and tcontains(el.classes, 'aside') then + el.classes = el.classes:filter(function(attr) + return attr ~= "aside" + end) + tappend(el.classes, {'column-margin'}) + return el + end + -- Handle full-width classes with wideblock + local side, clz = getWideblockSide(el.classes) + if side then + noteHasColumns() -- Ensure margin layout is activated for wideblock + el.classes = el.classes:filter(function(c) return c ~= clz end) + return make_typst_wideblock { + content = el.content, + side = side, + } + end + -- Handle margin figures/tables: propagate .column-margin class to FloatRefTarget + -- so they render with notefigure() instead of being wrapped in #note() + if hasMarginColumn(el) then + local floatRefTargets = el.content:filter(function(contentEl) + return is_custom_node(contentEl, "FloatRefTarget") + end) + if #floatRefTargets > 0 then + -- Propagate margin class and attributes to each FloatRefTarget and return unwrapped + local result = pandoc.Blocks({}) + for _, contentEl in ipairs(el.content) do + if is_custom_node(contentEl, "FloatRefTarget") then + local custom = _quarto.ast.resolve_custom_data(contentEl) + if custom ~= nil then + -- Add column-margin class to the float + if custom.classes == nil then + custom.classes = pandoc.List({'column-margin'}) + else + custom.classes:insert('column-margin') + end + -- Propagate margin-related attributes (shift, alignment, dy) + if el.attributes then + if custom.attributes == nil then + custom.attributes = {} + end + if el.attributes.shift then + custom.attributes.shift = el.attributes.shift + end + if el.attributes.alignment then + custom.attributes.alignment = el.attributes.alignment + end + if el.attributes.dy then + custom.attributes.dy = el.attributes.dy + end + end + result:insert(contentEl) + end + else + -- Non-float content stays wrapped in margin note + local inner_div = pandoc.Div({contentEl}, pandoc.Attr("", {'column-margin'})) + result:insert(inner_div) + end + end + return result + else + -- For cell-output-display divs with column-margin, the parent FloatRefTarget + -- will handle margin placement. Strip the class to prevent quarto-post from + -- wrapping it in #note() (which would cause double-wrapping with notefigure). + if el.classes:includes("cell-output-display") then + removeColumnClasses(el) + return el + end + end + end + elseif el.identifier and el.identifier:find("^lst%-") then -- for listings, fetch column classes from sourceCode element -- and move to the appropriate spot (e.g. caption, container div) @@ -175,7 +253,7 @@ local function def_columns() -- It also means that divs that want to be both a figure* and a table* -- will never work and we won't get the column-* treatment for -- everything, just for the table. - el.classes = el.classes:filter(function(clz) + el.classes = el.classes:filter(function(clz) return not isStarEnv(clz) end) end @@ -185,14 +263,14 @@ local function def_columns() -- the general purpose `sidenote` processing from capturing this -- element (since floats know how to deal with margin positioning) local custom = _quarto.ast.resolve_custom_data(contentEl) - if custom ~= nil then + if custom ~= nil then floatRefTarget = true removeColumnClasses(el) add_column_classes_and_attributes(columnClasses, columnAttributes, custom) end - end + end end - + if not figOrTable and not floatRefTarget then processOtherContent(el.content) end @@ -267,17 +345,29 @@ local function def_columns() Span = function(el) -- a span that should be placed in the margin - if _quarto.format.isLatexOutput() and hasMarginColumn(el) then + if _quarto.format.isLatexOutput() and hasMarginColumn(el) then noteHasColumns() tprepend(el.content, {latexBeginSidenote(false)}) tappend(el.content, {latexEndSidenote(el, false)}) removeColumnClasses(el) return el - else + elseif _quarto.format.isTypstOutput() and hasMarginColumn(el) then + -- For Typst, detect margin spans to trigger margin layout setup + -- Actual margin note rendering is handled in quarto-post/typst.lua + noteHasColumns() + -- Convert aside class to column-margin for consistency + if el.classes and tcontains(el.classes, 'aside') then + el.classes = el.classes:filter(function(attr) + return attr ~= "aside" + end) + tappend(el.classes, {'column-margin'}) + end + return el + else -- convert the aside class to a column-margin class if el.classes and tcontains(el.classes, 'aside') then noteHasColumns() - el.classes = el.classes:filter(function(attr) + el.classes = el.classes:filter(function(attr) return attr ~= "aside" end) tappend(el.classes, {'column-margin', 'margin-aside'}) diff --git a/src/resources/filters/layout/meta.lua b/src/resources/filters/layout/meta.lua index 349b89beaa8..a0ce5c53a7d 100644 --- a/src/resources/filters/layout/meta.lua +++ b/src/resources/filters/layout/meta.lua @@ -164,6 +164,42 @@ function layout_meta_inject_latex_packages() end end end + + -- enable column layout for Typst (configure page geometry for margin notes) + if (layoutState.hasColumns or marginReferences() or marginCitations()) and _quarto.format.isTypstOutput() then + -- Use specified papersize, or default to us-letter (matches Quarto's Typst template default) + local paperWidth = typstPaperWidth(meta.papersize) or kPaperWidthsIn["letter"] + if paperWidth then + -- Read margin options (margin.left, margin.right, margin.x) + local marginOptions = nil + if meta.margin then + marginOptions = { + left = meta.margin.left or meta.margin.x or nil, + right = meta.margin.right or meta.margin.x or nil, + } + end + + -- Read grid options (grid.margin-width, grid.gutter-width) + local gridOptions = nil + if meta.grid then + gridOptions = { + ["margin-width"] = meta.grid["margin-width"] or nil, + ["body-width"] = meta.grid["body-width"] or nil, + ["gutter-width"] = meta.grid["gutter-width"] or nil, + } + end + + meta["margin-layout"] = true + meta["margin-geometry"] = typstGeometryFromPaperWidth(paperWidth, marginOptions, gridOptions) + end + + -- Suppress bibliography when using margin citations (consistent with HTML behavior) + -- Full citations appear in margins, no end bibliography needed + if marginCitations() then + meta["suppress-bibliography"] = true + end + end + return meta end } @@ -336,4 +372,96 @@ function textWidth(width) return ((width - 2*left(width) - marginParSep(width)) * 2) / 3 end +-- Typst paper width lookup (reuse kPaperWidthsIn) +function typstPaperWidth(paperSize) + if paperSize ~= nil then + local paperSizeStr = pandoc.utils.stringify(paperSize) + -- Typst uses lowercase paper names, normalize input + paperSizeStr = string.lower(paperSizeStr) + -- Map some Typst-specific names + if paperSizeStr == "us-letter" then + paperSizeStr = "letter" + elseif paperSizeStr == "us-legal" then + paperSizeStr = "legal" + end + return kPaperWidthsIn[paperSizeStr] + end + return nil +end + +-- Parse CSS/Typst length values (e.g., "250px", "2.5in", "1.5em") +-- Returns value in inches, or nil if parsing fails +function parseCssLength(value) + if value == nil then return nil end + local str = pandoc.utils.stringify(value) + local num, unit = string.match(str, "^([%d%.]+)(%a+)$") + if num == nil then return nil end + num = tonumber(num) + if num == nil then return nil end + + -- Convert to inches for marginalia + if unit == "in" then + return num + elseif unit == "px" then + return num / 96 -- 96 DPI standard + elseif unit == "pt" then + return num / 72 + elseif unit == "cm" then + return num / 2.54 + elseif unit == "mm" then + return num / 25.4 + elseif unit == "em" then + return num * 11 / 72 -- Assume 11pt base font + else + return num -- Assume inches if no recognized unit + end +end + +-- Compute Typst geometry from paper width for marginalia package +-- Same ratios as LaTeX: 2/3 text, 1/3 margin +-- marginalia uses inner/outer with far+width+sep for each side +-- marginOptions: table with left, right keys (user margin overrides) +-- gridOptions: table with margin-width, gutter-width keys (user grid overrides) +function typstGeometryFromPaperWidth(paperWidth, marginOptions, gridOptions) + -- Start with auto-computed defaults from papersize + local innerSep = left(paperWidth) -- left margin + local outerFar = left(paperWidth) -- right padding (symmetric by default) + local outerWidth = marginParWidth(paperWidth) -- note column + local outerSep = marginParSep(paperWidth) -- gutter + + -- Apply margin.left override → inner.sep + if marginOptions and marginOptions.left then + local parsed = parseCssLength(marginOptions.left) + if parsed then innerSep = parsed end + end + + -- Apply margin.right override → outer.far + if marginOptions and marginOptions.right then + local parsed = parseCssLength(marginOptions.right) + if parsed then outerFar = parsed end + end + + -- Apply grid overrides + if gridOptions then + if gridOptions["margin-width"] then + local parsed = parseCssLength(gridOptions["margin-width"]) + if parsed then outerWidth = parsed end + end + if gridOptions["gutter-width"] then + local parsed = parseCssLength(gridOptions["gutter-width"]) + if parsed then outerSep = parsed end + end + -- body-width: if specified, could be used to validate or adjust, + -- but typically we let it be computed as the remainder + end + + return { + -- Inner (left) margin - no notes + ["inner-sep"] = string.format("%.3fin", innerSep), + -- Outer (right) margin - notes column + ["outer-far"] = string.format("%.3fin", outerFar), + ["outer-width"] = string.format("%.3fin", outerWidth), + ["outer-sep"] = string.format("%.3fin", outerSep), + } +end diff --git a/src/resources/filters/layout/typst.lua b/src/resources/filters/layout/typst.lua index 83fdcc3e2be..9e498cfeb06 100644 --- a/src/resources/filters/layout/typst.lua +++ b/src/resources/filters/layout/typst.lua @@ -1,6 +1,165 @@ -- typst.lua -- Copyright (C) 2023 Posit Software, PBC +-- Full-width column class mapping for wideblock +local widthClassToSide = { + ["column-page-right"] = "outer", + ["column-page-left"] = "inner", + ["column-page"] = "both", + ["column-screen"] = "both", + ["column-screen-inset"] = "both", + ["column-screen-inset-left"] = "inner", + ["column-screen-inset-right"] = "outer", + ["column-screen-left"] = "inner", + ["column-screen-right"] = "outer", +} + +-- Check if element has a full-width class and return the wideblock side +function getWideblockSide(classes) + if classes == nil then + return nil, nil + end + for clz, side in pairs(widthClassToSide) do + if classes:includes(clz) then + return side, clz + end + end + return nil, nil +end + +-- Wrap content in a wideblock for full-width layout +function make_typst_wideblock(tbl) + local content = tbl.content or pandoc.Blocks({}) + local side = tbl.side or "both" + + local result = pandoc.Blocks({}) + result:insert(pandoc.RawBlock("typst", '#wideblock(side: "' .. side .. '")[')) + result:extend(quarto.utils.as_blocks(content)) + result:insert(pandoc.RawBlock("typst", ']')) + result:insert(pandoc.RawBlock("typst", '\n\n')) + return result +end + +-- Helper to format shift parameter for marginalia +-- auto/true/false are unquoted, "avoid"/"ignore" are quoted strings +local function formatShiftParam(shift) + if shift == "true" or shift == "false" or shift == "auto" then + return shift + else + return '"' .. shift .. '"' + end +end + +-- Render a figure in the margin using marginalia's notefigure +function make_typst_margin_figure(tbl) + local content = tbl.content or pandoc.Div({}) + local caption = tbl.caption + local caption_location = tbl.caption_location or "bottom" + local identifier = tbl.identifier + local shift = tbl.shift or "auto" + local alignment = tbl.alignment or "baseline" + local dy = tbl.dy or "0pt" + local kind = tbl.kind or "quarto-float-fig" + local supplement = tbl.supplement or "Figure" + + local result = pandoc.Blocks({}) + + -- Start notefigure call with parameters + -- Include kind and supplement to share counter with regular figures + result:insert(pandoc.RawBlock("typst", + '#notefigure(alignment: "' .. alignment .. '", dy: ' .. dy .. + ', shift: ' .. formatShiftParam(shift) .. ', counter: none' .. + ', kind: "' .. kind .. '", supplement: "' .. supplement .. '", ')) + + -- Add figure content + result:insert(pandoc.RawBlock("typst", '[')) + -- Listings should not be centered inside the figure + if kind:match("lst") then + result:insert(pandoc.RawBlock("typst", '#set align(left)')) + end + result:extend(quarto.utils.as_blocks(content)) + result:insert(pandoc.RawBlock("typst", ']')) + + -- Add caption if present, with position control + if caption and not quarto.utils.is_empty_node(caption) then + result:insert(pandoc.RawBlock("typst", ', caption: figure.caption(position: ' .. caption_location .. ', [')) + if pandoc.utils.type(caption) == "Blocks" then + result:extend(caption) + else + result:insert(caption) + end + result:insert(pandoc.RawBlock("typst", '])')) + end + + -- Close notefigure + result:insert(pandoc.RawBlock("typst", ')')) + + -- Add label for cross-references + if identifier and identifier ~= "" then + result:insert(pandoc.RawBlock("typst", '<' .. identifier .. '>')) + end + + result:insert(pandoc.RawBlock("typst", '\n\n')) + return result +end + +-- Render a figure in main column with caption in margin +-- Uses marginalia's recommended show-rule approach for proper top-alignment +function make_typst_margin_caption_figure(tbl) + local content = tbl.content or pandoc.Div({}) + local caption = tbl.caption + local identifier = tbl.identifier + local kind = tbl.kind or "quarto-float-fig" + local supplement = tbl.supplement or "Figure" + -- Margin captions align with top of content (consistent with HTML visual behavior) + local alignment = tbl.alignment or "top" + + local result = pandoc.Blocks({}) + + -- Use marginalia's recommended approach: show rule transforms figure.caption into margin note + -- This ensures proper alignment because the caption anchors at the figure's position + local cap_position = alignment == "top" and "top" or "bottom" + local dy = alignment == "top" and "-0.01pt" or "0pt" + + -- Scoped show rule: transform figure captions into margin notes + result:insert(pandoc.RawBlock("typst", '#[')) + result:insert(pandoc.RawBlock("typst", '#set figure(gap: 0pt)')) + result:insert(pandoc.RawBlock("typst", '#set figure.caption(position: ' .. cap_position .. ')')) + result:insert(pandoc.RawBlock("typst", + '#show figure.caption: it => note(alignment: "' .. alignment .. '", dy: ' .. dy .. + ', counter: none, shift: "avoid", keep-order: true)[#text(size: 0.9em)[#it]]')) + + -- Render figure WITH caption - the show rule transforms it into a margin note + -- Typst's figure.caption already includes "Figure N:" prefix, so just include caption text + result:insert(pandoc.RawBlock("typst", '#figure([')) + -- Listings should not be centered inside the figure + if kind:match("lst") then + result:insert(pandoc.RawBlock("typst", '#set align(left)')) + end + result:extend(quarto.utils.as_blocks(content)) + result:insert(pandoc.RawBlock("typst", '], caption: [')) + if caption and not quarto.utils.is_empty_node(caption) then + if pandoc.utils.type(caption) == "Blocks" then + result:extend(caption) + else + result:insert(caption) + end + end + result:insert(pandoc.RawBlock("typst", + '], kind: "' .. kind .. '", supplement: "' .. supplement .. '")')) + + -- Add label for cross-references + if identifier and identifier ~= "" then + result:insert(pandoc.RawBlock("typst", '<' .. identifier .. '>')) + end + + -- Close scoping block + result:insert(pandoc.RawBlock("typst", ']')) + + result:insert(pandoc.RawBlock("typst", '\n\n')) + return result +end + function make_typst_figure(tbl) local content = tbl.content or pandoc.Div({}) local caption_location = tbl.caption_location diff --git a/src/resources/filters/quarto-post/typst.lua b/src/resources/filters/quarto-post/typst.lua index 6380dc8fee7..8480745de8a 100644 --- a/src/resources/filters/quarto-post/typst.lua +++ b/src/resources/filters/quarto-post/typst.lua @@ -9,6 +9,16 @@ local typst = require("modules/typst") _quarto.format.typst = typst +-- Helper to format marginalia shift parameter +-- auto/true/false are unquoted, "avoid"/"ignore" are quoted strings +local function formatShiftParam(shift) + if shift == "true" or shift == "false" or shift == "auto" then + return shift + else + return '"' .. shift .. '"' + end +end + function render_typst() if not _quarto.format.isTypstOutput() then return {} @@ -32,6 +42,31 @@ function render_typst() }, { Div = function(div) + -- Handle .column-margin divs (margin notes) using marginalia package + if div.classes:includes("column-margin") then + div.classes = div.classes:filter(function(c) return c ~= "column-margin" end) + + -- marginalia uses alignment for baseline/top/bottom positioning + local alignment = div.attributes.alignment or "baseline" + div.attributes.alignment = nil + + -- dy is for additional offset (0pt by default) + local dy = div.attributes.dy or "0pt" + div.attributes.dy = nil + + -- shift controls overlap prevention (auto, true, false, "avoid", "ignore") + local shift = div.attributes.shift or "auto" + div.attributes.shift = nil + + local result = pandoc.Blocks({}) + result:insert(pandoc.RawBlock("typst", + '#note(alignment: "' .. alignment .. '", dy: ' .. dy .. ', shift: ' .. formatShiftParam(shift) .. ', counter: none)[')) + result:extend(div.content) + result:insert(pandoc.RawBlock("typst", "]")) + return result + end + + -- Handle .block divs if div.classes:includes("block") then div.classes = div.classes:filter(function(c) return c ~= "block" end) @@ -52,6 +87,64 @@ function render_typst() return result end end, + Span = function(span) + -- Handle .column-margin spans (inline margin notes) using marginalia package + if span.classes:includes("column-margin") then + span.classes = span.classes:filter(function(c) return c ~= "column-margin" end) + + -- marginalia uses alignment for baseline/top/bottom positioning + local alignment = span.attributes.alignment or "baseline" + span.attributes.alignment = nil + + -- dy is for additional offset (0pt by default) + local dy = span.attributes.dy or "0pt" + span.attributes.dy = nil + + -- shift controls overlap prevention (auto, true, false, "avoid", "ignore") + local shift = span.attributes.shift or "auto" + span.attributes.shift = nil + + local result = pandoc.Inlines({}) + result:insert(pandoc.RawInline("typst", + '#note(alignment: "' .. alignment .. '", dy: ' .. dy .. ', shift: ' .. formatShiftParam(shift) .. ', counter: none)[')) + result:extend(span.content) + result:insert(pandoc.RawInline("typst", "]")) + return result + end + end, + Note = function(n) + -- Convert footnotes to sidenotes when reference-location: margin + if marginReferences() then + noteHasColumns() -- Activate margin layout + + -- Convert blocks to inlines for margin note + local content = pandoc.utils.blocks_to_inlines(n.content) + + local result = pandoc.Inlines({}) + result:insert(pandoc.RawInline("typst", "#quarto-sidenote[")) + result:extend(content) + result:insert(pandoc.RawInline("typst", "]")) + return result + end + end, + Cite = function(cite) + -- Show full citations in margin when citation-location: margin + if marginCitations() then + noteHasColumns() -- Activate margin layout + + -- Build citation keys for Typst labels + local keys = pandoc.List({}) + for _, c in ipairs(cite.citations) do + keys:insert("<" .. c.id .. ">") + end + local keyStr = table.concat(keys, ", ") + + -- Emit: quarto-margin-cite which renders inline cite + full cite in margin + local result = pandoc.Inlines({}) + result:insert(pandoc.RawInline("typst", "#quarto-margin-cite(" .. keyStr .. ")")) + return result + end + end, Header = function(el) if number_depth and el.level > number_depth then el.classes:insert("unnumbered") diff --git a/src/resources/filters/quarto-pre/parsefiguredivs.lua b/src/resources/filters/quarto-pre/parsefiguredivs.lua index 288204d3115..3562828ee2e 100644 --- a/src/resources/filters/quarto-pre/parsefiguredivs.lua +++ b/src/resources/filters/quarto-pre/parsefiguredivs.lua @@ -257,7 +257,18 @@ function parse_floatreftargets() elseif div.attributes[caption_attr_key] ~= nil then caption = pandoc.Plain(string_to_quarto_ast_inlines(div.attributes[caption_attr_key])) div.attributes[caption_attr_key] = nil - else + elseif ref == "lst" then + -- For listings from cell options, the caption may be on a nested CodeBlock + _quarto.ast.walk(content, { + CodeBlock = function(code) + if code.attr.attributes[caption_attr_key] then + caption = pandoc.Plain(string_to_quarto_ast_inlines(code.attr.attributes[caption_attr_key])) + code.attr.attributes[caption_attr_key] = nil + end + end + }) + end + if caption == nil then -- it's possible that the content of this div includes a table with a caption -- so we'll go root around for that. local found_caption = false @@ -331,26 +342,49 @@ function parse_floatreftargets() local layout_classes = attr.classes:filter( function(c) return c:match("^column-") end ) - if #layout_classes then - attr.classes = attr.classes:filter( - function(c) return not layout_classes:includes(c) end) - div.classes = div.classes:filter( - function(c) return not layout_classes:includes(c) end) - -- if the div is a cell, then all layout attributes need to be - -- forwarded to the cell .cell-output-display content divs - content = _quarto.ast.walk(content, { - Div = function(div) - if div.classes:includes("cell-output-display") then - div.classes:extend(layout_classes) - return _quarto.ast.walk(div, { - Table = function(tbl) - tbl.classes:insert("do-not-create-environment") - return tbl - end - }) + if #layout_classes > 0 then + -- Check if there are cell-output-display divs to forward to + local has_cell_output_display = false + _quarto.ast.walk(content, { + Div = function(subdiv) + if subdiv.classes:includes("cell-output-display") then + has_cell_output_display = true + end + end + }) + + if has_cell_output_display then + -- Forward layout classes to cell-output-display divs + content = _quarto.ast.walk(content, { + Div = function(subdiv) + if subdiv.classes:includes("cell-output-display") then + subdiv.classes:extend(layout_classes) + return _quarto.ast.walk(subdiv, { + Table = function(tbl) + tbl.classes:insert("do-not-create-environment") + return tbl + end + }) + end end + }) + -- Remove layout classes from div + div.classes = div.classes:filter( + function(c) return not layout_classes:includes(c) end) + -- For margin classes, keep on attr so FloatRefTarget can use notefigure + -- For fullwidth classes, strip from attr - columns.lua handles wideblock wrapping + local margin_classes = layout_classes:filter( + function(c) return c == "column-margin" or c == "aside" end) + local fullwidth_classes = layout_classes:filter( + function(c) return c ~= "column-margin" and c ~= "aside" end) + if #fullwidth_classes > 0 then + attr.classes = attr.classes:filter( + function(c) return not fullwidth_classes:includes(c) end) end - }) + -- margin_classes stay on attr for FloatRefTarget margin placement + end + -- If no cell-output-display (e.g., listings with echo:true eval:false), + -- keep layout_classes on attr so the FloatRefTarget inherits them end end diff --git a/src/resources/formats/typst/packages/preview/marginalia/0.3.1/README.md b/src/resources/formats/typst/packages/preview/marginalia/0.3.1/README.md new file mode 100644 index 00000000000..c763c3dc83c --- /dev/null +++ b/src/resources/formats/typst/packages/preview/marginalia/0.3.1/README.md @@ -0,0 +1,186 @@ +# Marginalia + +## Setup + +Put something akin to the following at the start of your `.typ` file: + +```typ +#import "@preview/marginalia:0.3.1" as marginalia: note, notefigure, wideblock + +#show: marginalia.setup.with( + // inner: ( far: 5mm, width: 15mm, sep: 5mm ), + // outer: ( far: 5mm, width: 15mm, sep: 5mm ), + // top: 2.5cm, + // bottom: 2.5cm, + // book: false, + // clearance: 12pt, +) +``` + +If `book` is `false`, `inner` and `outer` correspond to the left and right +margins respectively. If book is true, the margins swap sides on even and odd +pages. Notes are placed in the outside margin by default. + +Where you can then customize these options to your preferences. Shown here (as +comments) are the default values taken if the corresponding keys are unset. +[Please refer to the PDF documentation for more details on the configuration and the provided commands.](https://github.com/nleanba/typst-marginalia/blob/v0.3.1/Marginalia.pdf?raw=true) + +Additionally, I recommend using Typst's partial function application feature to +customize other aspects of the notes consistently: + +```typ +#let note = note.with(/* options here */) +#let notefigure = notefigure.with(/* same options here */) +``` + +## Margin-Notes + +Provided via the `#note[...]` command. + +_New in version 0.3.0:_ Notes can be labeled and referenced: If you write +`#note[]`, then `@xyz` just works! + +- `#note(side: "inner")[...]` to put it on the inside margin (left margin for + single-sided documents). + + Also accepts: `auto`=`"outer"`, `"left"`, `"right"`. + +- `#note(counter: none)[...]` to remove the marker. + + The display of the marker can be customized with the `numbering`, + `anchor-numbering`, and `flush-numbering` parameters. Refer to the docs for + details. + +- `#note(shift: false)[...]` to force exact position of the note. + + Also accepts `auto` (behavior depends on whether it has a marker), `true`, + `"avoid"` and `"ignore"`. + +- And more options for fine control. All details are in the docs. + +## Wide Blocks + +Provided via the `#wideblock[...]` command. + +- `#wideblock(side: "inner")[...]` to extend into the inside margin instead. + + Also accepts: `auto`=`"outer"`, `"left"`, `"right"`, or `"both"`. + +Note: Wideblocks do not handle pagebreaks well, especially in `book: true` +documents. This is a limitation of Typst which does not (yet) provide a robust +way of detecting and reacting to page breaks. + +## Figures + +You can use figures as normal, also within wideblocks. To get captions on the +side, use + +1. If you want top-aligned captions: + +```typ +#set figure(gap: 0pt) // neccessary in both cases +#set figure.caption(position: top) +#show figure.caption.where(position: top): note.with( + alignment: "top", counter: none, shift: "avoid", keep-order: true, + dy: -0.01pt, // this is so that the caption is placed above wide figures. +) +``` + +2. If you want bottom-aligned captions: + +```typ +#set figure(gap: 0pt) // neccessary in both cases +#set figure.caption(position: bottom) // (this is the default) +#show figure.caption.where(position: bottom): note.with( + alignment: "bottom", counter: none, shift: "avoid", keep-order: true) +``` + +### Figures in the Margin + +For small figures, the package also provides a `notefigure` command which places +the figure in the margin. + +```typ +#notefigure( + rect(width: 100%), + caption: [A notefigure.], +) +``` + +It takes all the same options as `#note[]`, with some additions. In particular, + +- You can use `#notefigure(note-label: , ..)` to label the underlying note + (if you want to reference it like a note) + +- `#notefigure(show-caption: .., ..)` is how you change the caption rendering. + NB.: this function is expected to take two arguments, please consult the docs. + +## Utilities + +- `#marginalia.header()` for easy two/three-column headers +- `#show: marginalia.show-frame` to show the page layout with background lines +- `#marginalia.note-numbering()` to generate your own numberings from sequences + of symbols +- `#marginalia.ref()` to reference notes by relative index, without using + labels. +- `#marginalia.get-left()` and `#marginalia.get-right()` to get contextual + layout information. + +## Manual + +[Full Manual →](https://github.com/nleanba/typst-marginalia/blob/v0.3.1/Marginalia.pdf?raw=true) +[![first page of the documentation](https://github.com/nleanba/typst-marginalia/raw/refs/tags/v0.3.1/preview.svg)](https://github.com/nleanba/typst-marginalia/blob/v0.3.1/Marginalia.pdf?raw=true) + +### Changelog + +- 0.3.1 + - Text written right-to-left is now supported. +- 0.3.0 + - Notes and notefigures can now be labeled and referenced. The `label` + parameter on notefigures has been removed, a `note-label` parameter has been + added. + - Recommended way to disable numbering a note is using `counter: none`, this + now works uniformly for notes and notefigures. (Enable markers for a + notefigure simply by using `counter: marginalia.notecounter`) + - Note markers now link to the anchor (and vice versa). Controlled via new + `link-anchor` property. +- 0.2.4 + - Pages with `height: auto` work now. + - Added `ref` utility function. +- 0.2.3: The counter used for notes can now be customized. +- 0.2.2 + - More flexible `alignment` parameter replaces `align-baseline`. + (`notefigure.dy` no longer takes a relative length, use `alignemnt` + instead.) + - Added `show-frame` and `header` utility functions. +- 0.2.1: Allow customizing the anchor independently of the in-note number using + `anchor-numbering`. +- 0.2.0 + - `block-style` can now be a function, allowing to customize the style for + even and odd pages. + - `reverse` and `double` parameters have been replaced by a uniform `side` + parameter. + - Setup is now done using the `setup` show-rule. (`configure()` and + `page-setup()` have been removed) + - `numbering` and `flush-numbering` parameters are now per-note. (`numbered` + has been removed.) +- 0.1.4: New styling parameter `block-style`. +- 0.1.3 + - New styling parameters `par-style`, and `text-style`. + - Added `shift` and `keep-order` options. +- 0.1.1 + - Notes will now avoid each other for any amount of notes. + - Added `notefigure`. + - Added `clearance` configuration option. + +(Not listing bugfixes, see the +[release notes on GitHub](https://github.com/nleanba/typst-marginalia/releases) +for the full details.) + +## Feedback + +Have you encountered a bug? +[Please report it as an issue in my GitHub repository.](https://github.com/nleanba/typst-marginalia/issues) + +Has this package been useful to you? +[I am always happy when someone gives me a ~~sticker~~ star⭐](https://github.com/nleanba/typst-marginalia) diff --git a/src/resources/formats/typst/packages/preview/marginalia/0.3.1/UNLICENSE b/src/resources/formats/typst/packages/preview/marginalia/0.3.1/UNLICENSE new file mode 100644 index 00000000000..fdddb29aa44 --- /dev/null +++ b/src/resources/formats/typst/packages/preview/marginalia/0.3.1/UNLICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/src/resources/formats/typst/packages/preview/marginalia/0.3.1/lib.typ b/src/resources/formats/typst/packages/preview/marginalia/0.3.1/lib.typ new file mode 100644 index 00000000000..9810cd956ae --- /dev/null +++ b/src/resources/formats/typst/packages/preview/marginalia/0.3.1/lib.typ @@ -0,0 +1,1294 @@ + +/* Config Setup */ + +/// The default counter used for the note icons. +/// +/// If you use @note-numbering without @note-numbering.repeat, it is recommended you reset this occasionally, e.g. per heading or per page. +/// #example(scale-preview: 100%, ```typc notecounter.update(1)```) +/// -> counter +#let notecounter = counter("marginalia-note") + +/// Icons to use for note markers. +/// +/// ```typc ("◆", "●", "■", "▲", "♥", "◇", "○", "□", "△", "♡")``` +#let note-markers = ("◆", "●", "■", "▲", "♥", "◇", "○", "□", "△", "♡") +/// Icons to use for note markers, alternating filled/outlined. +/// +/// ```typc ("●", "○", "◆", "◇", "■", "□", "▲", "△", "♥", "♡")``` +#let note-markers-alternating = ("●", "○", "◆", "◇", "■", "□", "▲", "△", "♥", "♡") + +/// Format note marker. +/// -> content +#let note-numbering( + /// #example(```typ + /// #for i in array.range(1,15) { + /// note-numbering(markers: note-markers, i) } + /// + /// #for i in array.range(1,15) { + /// note-numbering(markers: note-markers-alternating, i) } + /// + /// #for i in array.range(1,15) { + /// note-numbering(markers: (), i) } + /// ```) + /// -> array + markers: note-markers-alternating, + /// Whether to (```typc true```) loop over the icons, or (```typc false```) continue with numbers after icons run out. + /// #example(```typ + /// #for i in array.range(1,15) { + /// note-numbering(repeat: true, i) } + /// + /// #for i in array.range(1,15) { + /// note-numbering(repeat: false, i) } + /// ```) + /// -> bool + repeat: true, + /// Wrap the symbol in a styled text function. + /// -> function + style: text.with(weight: 900, font: "Inter", size: 5pt, style: "normal", fill: rgb(54%, 72%, 95%)), + /// Whether to add a space of 2pt after the symbol. + /// If ```typc auto```, a space is only added if it is a number (the symbols have ran out). + /// -> auto | bool + space: auto, + .., + /// -> int + i, +) = { + let index = if repeat and markers.len() > 0 { calc.rem(i - 1, markers.len()) } else { i - 1 } + let symbol = if index < markers.len() { + markers.at(index) + if space == true { h(2pt) } + } else { + str(index + 1 - markers.len()) + if space == true or space == auto { h(2pt) } + } + style(symbol) +} + +///#internal() +#let _fill_config(..config) = { + let config = config.named() + // default margins a4 are 2.5 cm + let inner = config.at("inner", default: (far: 5mm, width: 15mm, sep: 5mm)) + let outer = config.at("outer", default: (far: 5mm, width: 15mm, sep: 5mm)) + return ( + inner: ( + far: inner.at("far", default: 5mm), + width: inner.at("width", default: 15mm), + sep: inner.at("sep", default: 5mm), + ), + outer: ( + far: outer.at("far", default: 5mm), + width: outer.at("width", default: 15mm), + sep: outer.at("sep", default: 5mm), + ), + top: config.at("top", default: 2.5cm), + bottom: config.at("bottom", default: 2.5cm), + book: config.at("book", default: false), + clearance: config.at("clearance", default: 12pt), + ) +} + +#let _config = state("_config", _fill_config()) + +/// Page setup helper +/// +/// This will generate a dictionary ```typc ( margin: .. )``` compatible with the passed config. +/// This can then be spread into the page setup like so: +///```typ +/// #set page( ..page-setup(..config) )``` +/// +/// Takes the same options as @setup. +/// -> dictionary +#let _page-setup( + /// Missing entries are filled with package defaults. Note: missing entries are _not_ taken from the current marginalia config, as this would require context. + /// -> dictionary + ..config, +) = { + let config = _fill_config(..config) + if config.book { + return ( + binding: left, + margin: ( + inside: config.inner.far + config.inner.width + config.inner.sep, + outside: config.outer.far + config.outer.width + config.outer.sep, + top: config.top, + bottom: config.bottom, + ), + ) + } else { + return ( + binding: left, + margin: ( + left: config.inner.far + config.inner.width + config.inner.sep, + right: config.outer.far + config.outer.width + config.outer.sep, + top: config.top, + bottom: config.bottom, + ), + ) + } +} + +/// This will update the marginalia config and setup the page with the provided config options. +/// (This means this will insert a pagebreak.) +/// +/// Use as +/// ```typ +/// #show: marginalia.setup.with(/* options here */) +/// ``` +/// +/// The default values for the margins have been chosen such that they match the default typst margins for a4. It is strongly recommended to change at least one of either `inner` or `outer` to be wide enough to actually contain text. +/// +/// This function also sets up the neccesary show-rule to allow referencing labelled notes. +/// If you also have a custom ```typ #show ref:``` rule, it may be relevant if setup is called before or after that show rule. +/// +/// #compat(( +/// "0.1.5": ( +/// [`numbering` has been replaced with @note.numbering/@notefigure.numbering. +/// #ergo[set \````typc numbering: /**/```\` directly on your notes instead of via @setup.\ Use ```typ #let note = note.with(numbering: /**/)``` for consistency.]], +/// [`flush-numbers` has been replaced by @note.flush-numbering. +/// #ergo[set \````typc flush-numbering: true```\` directly on your notes instead of via @setup.\ Use ```typ #let note = note.with(flush-numbering: /**/)``` for consistency.]], +/// ), +/// "0.2.0": ( +/// [This function does no longer apply the configuration partially, but will reset all unspecified options to the default. +/// Additionally, it replaces the `page-setup()` function that was needed previously and is no longer called `configure()`], +/// ), +/// )) +#let setup( + /// Inside/left margins. + /// - `far`: Distance between edge of page and margin (note) column. + /// - `width`: Width of the margin column. + /// - `sep`: Distance between margin column and main text column. + /// + /// The page inside/left margin should equal `far` + `width` + `sep`. + /// + /// If partial dictionary is given, it will be filled up with defaults. + /// -> dictionary + inner: (far: 5mm, width: 15mm, sep: 5mm), + /// Outside/right margins. Analogous to `inner`. + /// -> dictionary + outer: (far: 5mm, width: 15mm, sep: 5mm), + /// Top margin. + /// -> length + top: 2.5cm, + /// Bottom margin. + /// -> length + bottom: 2.5cm, + ///- If ```typc true```, will use inside/outside margins, alternating on each page. + ///- If ```typc false```, will use left/right margins with all pages the same. + /// -> bool + book: false, + /// Minimal vertical distance between notes and to wide blocks. + /// -> length + clearance: 12pt, + /// -> content + body, +) = { } +#let setup(..config, body) = { + _config.update(_fill_config(..config)) + set page(.._page-setup(..config)) + show ref: it => { + if ( + it.has("element") + and it.element != none + and it.element.has("children") + and it.element.children.len() > 1 + and it.element.children.first().func() == metadata + ) { + if it.element.children.first().value == "_marginalia_note" { + h(0pt, weak: true) + show link: it => { + show underline: i => i.body + it + } + let dest = query(selector(<_marginalia_note>).after(it.element.location())) + assert(dest.len() > 0, message: "Could not find referenced note") + link(dest.first().location(), dest.first().value.anchor) + } else if it.element.children.first().value == "_marginalia_notefigure" { + let dest = query(selector(<_marginalia_notefigure_meta>).after(it.element.location())) + assert(dest.len() > 0, message: "Could not find referenced notefigure") + if it.has("form") { + std.ref(dest.first().value.label, form: it.form, supplement: it.supplement) + } else { + std.ref(dest.first().value.label, supplement: it.supplement) + } + } else { + it + } + } else { + it + } + } + body +} + +/// // #internal[(mostly for internal use)] +/// Returns a dictionary with the keys `far`, `width`, `sep` containing the respective widths of the +/// left margin on the current page. (On both even and odd pages.) +/// +/// Requires context. +/// -> dictionary +#let get-left() = { + let config = _config.get() + if not (config.book) or calc.odd(here().page()) { + return config.inner + } else { + return config.outer + } +} + +/// // #internal[(mostly for internal use)] +/// Returns a dictionary with the keys `far`, `width`, `sep` containing the respective widths of the +/// right margin on the current page. (On both even and odd pages.) +/// +/// Requires context. +/// -> dictionary +#let get-right() = { + let config = _config.get() + if not (config.book) or calc.odd(here().page()) { + return config.outer + } else { + return config.inner + } +} + +/// Adds lines to the page background showing the various vertical and horizontal boundaries used by marginalia. +/// +/// To be used in a show-rule: +/// ```typ +/// #show: marginalia.show-frame +/// ``` +/// -> content +#let show-frame( + /// Stroke for the lines. + /// + /// ```typ + /// #show: marginalia.show-frame.with(stroke: 2pt + red) + /// ``` + /// -> color + stroke: 0.5pt + luma(90%), + /// Set to false to hide the header line + /// -> bool + header: true, + /// Set to false to hide the footer line + /// -> bool + footer: true, + /// -> content + body, +) = { + set page( + background: context { + let leftm = get-left() + let rightm = get-right() + + let topm = _config.get().top + let ascent = page.header-ascent.ratio * topm + page.header-ascent.length + place(top, dy: topm, line(length: 100%, stroke: stroke)) + if header { + place(top, dy: topm - ascent, line(length: 100%, stroke: stroke)) + } + + let bottomm = _config.get().bottom + let descent = page.footer-descent.ratio * bottomm + page.footer-descent.length + place(bottom, dy: -bottomm, line(length: 100%, stroke: stroke)) + if footer { + place(bottom, dy: -bottomm + descent, line(length: 100%, stroke: stroke)) + } + + place(left, dx: leftm.far, rect(width: leftm.width, height: 100%, stroke: (x: stroke))) + place(left, dx: leftm.far + leftm.width + leftm.sep, line(length: 100%, stroke: stroke, angle: 90deg)) + + place(right, dx: -rightm.far, rect(width: rightm.width, height: 100%, stroke: (x: stroke))) + place(right, dx: -rightm.far - rightm.width - rightm.sep, line(length: 100%, stroke: stroke, angle: 90deg)) + }, + ) + + body +} + +/// #internal[(mostly for internal use)] +/// Calculates positions for notes. +/// +/// Return type is of the form `(: offset)` +/// -> dictionary +#let _calculate-offsets( + /// Of the form + /// ```typc + /// ( + /// height: length, // total page height + /// top: length, // top margin + /// bottom: length, // bottom margin + /// ) + /// ``` + /// -> dictionary + page, + /// Of the form `(: item)` where items have the form + /// ```typc + /// ( + /// natural: length, // initial vertical position of item, relative to page + /// height: length, // vertical space needed for item + /// clearance: length, // vertical padding required. + /// // may be collapsed at top & bottom of page, and above separators + /// shift: bool | "ignore" | "avoid", // whether the item may be moved about. `auto` = move only if neccessary + /// keep-order: bool, // if false, may be reordered. if true, order relative to other `false` items is kept + /// ) + /// ``` + /// -> dictionary + items, + /// -> length + clearance, +) = { + // sorting + let ignore = () + let reoderable = () + let nonreoderable = () + for (key, item) in items.pairs() { + if item.shift == "ignore" { + ignore.push(key) + } else if item.keep-order == false { + reoderable.push((key, item.natural)) + } else { + nonreoderable.push((key, item.natural)) + } + } + reoderable = reoderable.sorted(key: ((_, pos)) => pos) + + let positions = () + + let index_r = 0 + let index_n = 0 + while index_r < reoderable.len() and index_n < nonreoderable.len() { + if reoderable.at(index_r).at(1) <= nonreoderable.at(index_n).at(1) { + positions.push(reoderable.at(index_r)) + index_r += 1 + } else { + positions.push(nonreoderable.at(index_n)) + index_n += 1 + } + } + while index_n < nonreoderable.len() { + positions.push(nonreoderable.at(index_n)) + index_n += 1 + } + while index_r < reoderable.len() { + positions.push(reoderable.at(index_r)) + index_r += 1 + } + + // shift down + let cur = page.top + let empty = 0pt + let prev-shift-avoid = false + let positions_d = () + for (key, position) in positions { + let fault = cur - position + if cur <= position { + positions_d.push((key, position)) + if items.at(key).shift == false { + empty = 0pt + } else { + empty += position - cur + } + cur = position + items.at(key).height + clearance + } else if items.at(key).shift == "avoid" { + if fault <= empty { + if prev-shift-avoid { + positions_d.push((key, cur)) + cur = cur + items.at(key).height + clearance + } else { + // can stay + positions_d.push((key, position)) + empty -= fault // ? + cur = position + items.at(key).height + clearance + } + } else { + if prev-shift-avoid { + positions_d.push((key, cur)) + cur = cur + items.at(key).height + clearance + } else { + positions_d.push((key, position + fault - empty)) + cur = position + fault - empty + items.at(key).height + clearance + empty = 0pt + } + } + } else if items.at(key).shift == false { + // check if we can swap with previous + if ( + positions_d.len() > 0 + and fault > empty + and items.at(positions_d.last().at(0)).shift != false + and ((not items.at(key).keep-order) or (not items.at(positions_d.last().at(0)).keep-order)) + ) { + let (prev, _) = positions_d.pop() + cur -= items.at(prev).height + positions_d.push((key, position)) + empty = 0pt + let new_x = calc.max(position + items.at(key).height + clearance, cur) + cur = new_x + positions_d.push((prev, cur)) + cur = cur + items.at(prev).height + clearance + } else { + positions_d.push((key, position)) + empty = 0pt + cur = calc.max(position + items.at(key).height + clearance, cur) + } + } else { + positions_d.push((key, cur)) + empty += 0pt + // empty = 0pt + cur = cur + items.at(key).height + clearance + } + prev-shift-avoid = items.at(key).shift == "avoid" + } + + let positions = () + + let max = if page.height == auto { + if positions_d.len() > 0 { + let (key, position) = positions_d.at(-1) + position + items.at(key).height + } else { 0pt } + } else { + page.height - page.bottom + } + for (key, position) in positions_d.rev() { + if max > position + items.at(key).height { + positions.push((key, position)) + max = position - clearance + } else if items.at(key).shift == false { + positions.push((key, position)) + max = calc.min(position - clearance, max) + } else { + positions.push((key, max - items.at(key).height)) + max = max - items.at(key).height - clearance + } + } + + let result = (:) + for (key, position) in positions { + result.insert(key, position - items.at(key).natural) + } + for key in ignore { + result.insert(key, 0pt) + } + result +} + +// #let _parent-note = state("_marginalia_parent-note-natural", false) + +#let _note_extends_left = state("_note_extends_left", (:)) +// #let _note_offsets_left = state("_note_offsets_left", (:)) + +#let _note_extends_right = state("_note_extends_right", (:)) +// #let _note_offsets_right = state("_note_offsets_right", (:)) + + +// Internal use. +// Requires Context. +// -> "left" | "right" +#let _get-near-side() = { + let anchor = here().position() + let pagewidth = if page.flipped { page.height } else { page.width } + let left = get-left() + let right = get-right() + if ( + anchor.x - left.far - left.width - left.sep + < (pagewidth - left.far - left.width - left.sep - right.far - right.width - right.sep) / 2 + ) { + "left" + } else { + "right" + } +} + +// Internal use. +#let place-note( + /// -> "right" | "left" | "near" + side: "right", + dy: 0pt, + keep-order: false, + shift: true, + body, +) = ( + box( + width: 0pt, + context { + assert(side == "left" or side == "right" or side == "near", message: "side must be left or right.") + + let dy = dy.to-absolute() + let anchor = here().position() + let pagewidth = if page.flipped { page.height } else { page.width } + let page_num = str(anchor.page) + + let side = if side == "near" { _get-near-side() } else { side } + + let width = if side == "left" { get-left().width } else { get-right().width } + let height = measure(body, width: width).height + let notebox = box(width: width, height: height, body) + let natural_position = anchor.y + dy + + let extends = if side == "right" { _note_extends_right } else { _note_extends_left } + // let offsets = if side == "right" { _note_offsets_right } else { _note_offsets_left } + + let current = extends.get().at(page_num, default: ()) + let index = current.len() + + extends.update(old => { + let oldpage = old.at(page_num, default: ()) + oldpage.push((natural: natural_position, height: height, shift: shift, keep-order: keep-order)) + old.insert(page_num, oldpage) + old + }) + + let offset_page = ( + height: if page.flipped { page.width } else { page.height }, + bottom: _config.get().bottom, + top: _config.get().top, + ) + let offset_items = extends + .final() + .at(page_num, default: ()) + .enumerate() + .map(((key, item)) => (str(key), item)) + .to-dict() + let offset_clearance = _config.get().clearance + let dbg = _calculate-offsets(offset_page, offset_items, offset_clearance) + // TODO: trying to cache the results does not work. + // offsets.update(old => { + // // only do calculations if not yet in old + // if page_num in old { + // old + // } else { + // let new_offsets = _calculate-offsets(offset_page, offset_items, offset_clearance) + // assert(dbg == new_offsets) + // old.insert(page_num, new_offsets) + // old + // } + // }) + + // let vadjust = dy + offsets.final().at(page_num, default: (:)).at(str(index), default: 0pt) + let vadjust = dy + dbg.at(str(index), default: 0pt) + + // box(width: 0pt, place(box(fill: yellow, width: 1cm, text(size: 5pt)[#anchor.y + #vadjust = #(anchor.y + vadjust)]))) + + let hadjust = if side == "left" { get-left().far - anchor.x } else { + pagewidth - anchor.x - get-right().far - get-right().width + } + + place(left, dx: hadjust, dy: vadjust, notebox) + }, + ) +) + +/// Create a marginnote. +/// Will adjust it's position downwards to avoid previously placed notes, and upwards to avoid extending past the bottom margin. +/// +/// Notes can be attached a label and are referenceable (if @setup was run). +/// +/// #compat(( +/// "0.1.5": ( +/// [`reverse` has been replaced with @note.side. +/// #ergo[use \````typc side: "inner"```\` instead of \````typc reverse: true```\`]], +/// [`numbered` has been replaced with @note.numbering. +/// #ergo[use \````typc numbering: "none"```\` instead of \````typc numbered: false```\`]], +/// ), +/// "0.2.2": ( +/// [`align-baseline` has been replaced with @note.alignment. +/// #ergo[use \````typc alignment: "top"```\` instead of \````typc align-baseline: false```\`]], +/// ), +/// )) +#let note( + /// Counter to use for this note. + /// Can be set to ```typc none``` do disable numbering this note. + /// + /// Will only be stepped if `numbering` is not ```typc none```. + /// -> counter | none + counter: notecounter, + /// Function or `numbering`-string to generate the note markers from the `notecounter`. + /// - If ```typc none```, will not step the `counter`. + /// - Will be ignored if `counter` is ```typc none```. + /// + /// Examples: + /// - ```typc (..i) => super(numbering("1", ..i))``` for superscript numbers + /// #note(numbering: (..i) => super(numbering("1", ..i)))[E.g.] + /// - ```typc (..i) => super(numbering("a", ..i))``` for superscript letters + /// #note(numbering: (..i) => super(numbering("a", ..i)))[E.g.] + /// - ```typc marginalia.note-numbering.with(repeat: false, markers: ())``` for small blue numbers + /// #note(numbering: marginalia.note-numbering.with(repeat: false, markers: ()))[E.g.] + /// -> none | function | string + numbering: note-numbering, + /// Used to generate the marker for the anchor (i.e. the one in the surrounding text) + /// + /// - If ```typc auto```, will use the given @note.numbering. + /// - Will be ignored if `counter` is ```typc none```. + /// -> none | auto | function | string + anchor-numbering: auto, + /// Whether to have the anchor link to the note, and vice-versa. + /// -> bool + link-anchor: true, + /// Disallow note markers hanging into the whitespace. + /// - If ```typc auto```, acts like ```typc false``` if @note.anchor-numbering is ```typc auto```. + /// -> auto | bool + flush-numbering: auto, + /// Which side to place the note. + /// ```typc auto``` defaults to ```typc "outer"```. + /// In non-book documents, ```typc "outer"```/```typc "inner"``` are equivalent to ```typc "right"```/```typc "left"``` respectively. + /// ```typc "near"``` will place the note in the left or right margin, depending which is nearer. + /// -> auto | "outer" | "inner" | "left" | "right" | "near" + side: auto, + /// Vertical alignment of the note. + /// #let note = note.with(block-style: (outset: (left: 5cm), fill: oklch(70%, 0.1, 120deg, 20%)), shift: "ignore") + /// - ```typc "bottom"``` aligns the bottom edge of the note with the main text baseline.#note(alignment: "bottom")[Bottom\ ...] + /// - ```typc "baseline"``` aligns the first baseline of the note with the main text baseline.#note(alignment: "baseline")[Baseline\ ...] + /// - ```typc "top"``` aligns the top edge of the note with the main text baseline.#note(alignment: "top")[Top\ ...] + /// + /// -> "baseline" | "top" | "bottom" + alignment: "baseline", + /// Inital vertical offset of the note, relative to the alignment point. + /// The note may get shifted still to avoid other notes depending on @note.shift. + /// -> length + dy: 0pt, + /// Notes with ```typc keep-order: true``` are not re-ordered relative to one another. + /// + /// // If ```typc auto```, defaults to false unless ```typc numbering``` is ```typc none``. + /// // -> bool | auto + /// -> bool + keep-order: false, + /// Whether the note may get shifted vertically to avoid other notes. + /// - ```typc true```: The note may shift to avoid other notes, wide-blocks and the top/bottom margins. + /// - ```typc false```: The note is placed exactly where it appears, and other notes may shift to avoid it. + /// - ```typc "avoid"```: The note is only shifted if shifting other notes is not sufficent to avoid a collision. + /// E.g. if it would collide with a wideblock or a note with ```typc shift: false```. + /// - ```typc "ignore"```: Like ```typc false```, but other notes do not try to avoid it. + /// - ```typc auto```: ```typc true``` if numbered, ```typc "avoid"``` otherwise. + /// -> bool | auto | "avoid" | "ignore" + shift: auto, + /// Will be used to ```typc set``` the text style. + /// -> dictionary + text-style: (size: 9.35pt, style: "normal", weight: "regular"), + /// Will be used to ```typc set``` the par style. + /// -> dictionary + par-style: (spacing: 1.2em, leading: 0.5em, hanging-indent: 0pt), + /// Will be passed to the `block` containing the note body. + /// If this is a function, it will be called with ```typc "left"``` or ```typc "right"``` as its argument, and the result is passed to the `block`. + /// -> dictionary | function + block-style: (width: 100%), + /// -> content + body, +) = { + metadata("_marginalia_note") + + let numbering = if counter == none { none } else { numbering } + if numbering != none { counter.step() } + let flush-numbering = if flush-numbering == auto { anchor-numbering != auto } else { flush-numbering } + let anchor-numbering = if anchor-numbering == auto { numbering } else { anchor-numbering } + + // let keep-order = if keep-order == auto { not numbered } else { keep-orders } + let shift = if shift == auto { if numbering != none { true } else { "avoid" } } else { shift } + + let text-style = (size: 9.35pt, style: "normal", weight: "regular", ..text-style) + let par-style = (spacing: 1.2em, leading: 0.5em, hanging-indent: 0pt, ..par-style) + + context { + let side = if side == "outer" or side == auto { + if _config.get().book and calc.even(here().page()) { "left" } else { "right" } + } else if side == "inner" { + if _config.get().book and calc.even(here().page()) { "right" } else { "left" } + } else { side } + + assert( + side == "left" or side == "right" or side == "near", + message: "side must be auto, left, right, near, outer, or inner.", + ) + let body = if numbering != none { + let number = { + if link-anchor { + show link: it => { + show underline: i => i.body + it + } + link(here(), counter.display(numbering)) + } else { + counter.display(numbering) + } + } + if flush-numbering { + box(number) + h(0pt, weak: true) + body + } else { + body + let width = measure({ + set text(..text-style) + set par(..par-style) + number + }).width + if width < 8pt { width = 8pt } + place( + top + start, + { + h(-width) // HACK: uses `h` instad of `dx` so it works in ltr and rtl contexts + box( + width: width, + { + h(1fr) + sym.zws + number + h(1fr) + }, + ) + }, + ) + } + } else { + body + } + + let block-style = if type(block-style) == function { + block-style(side) + } else { + block-style + } + + let anchor = if anchor-numbering != none and counter != none { + counter.display(anchor-numbering) + } else [] + + let body = align( + top, + block( + width: 100%, + ..block-style, + align( + start, + { + // HACK: inner align ensures text-direction is unaffected by `place(left,..)` + set text(..text-style) + set par(..par-style) + [#metadata((note: true, anchor: anchor))<_marginalia_note>#body] + }, + ), + ), + ) + + let dy-adjust = if alignment == "baseline" { + measure(text(..text-style, sym.zws)).height + } else if alignment == "top" { + 0pt + } else if alignment == "bottom" { + let width = if side == "left" { + get-left().width + } else { + get-right().width + } + measure(width: width, body).height + } else { + panic("Unknown value for alignment") + } + let dy = dy - dy-adjust + + h(0pt, weak: true) + box({ + if anchor-numbering != none { + if link-anchor { + show link: it => { + show underline: i => i.body + it + } + let dest = query(selector(<_marginalia_note>).after(here())) + if dest.len() > 0 { + link(dest.first().location(), anchor) + } else { + anchor + } + } else { + anchor + } + } + place-note(side: side, dy: dy, keep-order: keep-order, shift: shift, body) + }) + } +} + +/// Reference a nearby margin note. Will place the same anchor as that note had. +/// +/// Be aware that notes without an anchor (including notefigures) still count for the offset, but the rendered link is empty. +/// +/// #example(scale-preview: 100%, ```typ +/// This is a note: #note[Blah Blah] +/// +/// This is a link to that note: +/// #marginalia.ref(-1) +/// +/// This is an unnumbered note: +/// #note(counter: none)[Blah Blah] +/// +/// This is a useless link to that note: +/// #marginalia.ref(-1) +/// ```) +#let ref( + /// How many notes away the target note is. + /// - ```typc -1```: The previous note. + /// - ```typc 0```: Disallowed + /// - ```typc 1```: The next note. + /// -> integer + offset, +) = context { + h(0pt, weak: true) + show link: it => { + show underline: i => i.body + it + } + assert(offset != 0, message: "marginalia.ref offset must not be 0.") + if offset > 0 { + let dest = query(selector(<_marginalia_note>).after(here())) + assert(dest.len() > offset, message: "Not enough notes after this to reference") + link(dest.at(offset - 1).location(), dest.at(offset - 1).value.anchor) + } else { + let dest = query(selector(<_marginalia_note>).before(here())) + assert(dest.len() >= -offset, message: "Not enough notes before this to reference") + link(dest.at(offset).location(), dest.at(offset).value.anchor) + } +} + +/// Creates a figure in the margin. +/// +/// Parameters `numbering`, `anchor-numbering`, `flush-numbering`, `side`, `keep-order`, `shift`, `text-style`, `par-style`, and `block-style` work the same as for @note. +/// +/// Notefigures can be attached a label and are referenceable (if @setup was run). Furthermore, the underlying @note can be given a label using the `note-label` parameter. +/// +/// #compat(( +/// "0.1.5": ( +/// [`reverse` has been replaced with @notefigure.side. +/// #ergo[use \````typc side: "inner"```\` instead of \````typc reverse: true```\`]], +/// [`numbered` has been replaced with @notefigure.numbering. +/// #ergo[use \````typc numbering: marginalia.note-numbering```\` instead of \````typc numbered: true```\`]], +/// ), +/// "0.2.2": ( +/// [@notefigure.dy no longer takes a relative length, instead @notefigure.alignment was added.], +/// ), +/// "0.3.0": ( +/// [The `label` argument has been removed. +/// #ergo[Instead of ```typ #notefigure(label: , ..)```, use ```typ #notefigure(..)```.]], +/// ) +/// )) +/// -> content +#let notefigure( + /// Same as @note.numbering, but with different default. + /// Set this to `marginalia.notecounter` (or another counter) to enable numbering this note. + /// + /// Will only be stepped if `numbering` is not ```typc none```. + /// + /// #example(scale-preview: 100%, dir: ttb, ```typ + /// Notefigure with marker: + /// #notefigure(rect(height: 10pt, width: 100%), caption: [...], counter: marginalia.notecounter) + /// ```) + /// #example(scale-preview: 100%, dir: ttb, ```typ + /// Using the figure counter for the numbering: + /// #notefigure( + /// rect(height: 10pt, width: 100%), caption: [...], + /// counter: counter(figure.where(kind: image)), + /// anchor-numbering: (.., i) => super[fig. #numbering("1", i+1)], numbering: none, + /// ) + /// ```) + /// -> counter | none + counter: none, + /// Same as @note.numbering. + /// -> none | function | string + numbering: note-numbering, + /// Same as @note.anchor-numbering. + /// -> none | auto | function | string + anchor-numbering: auto, + /// Whether to have the anchor link to the note, and vice-versa. + /// -> bool + link-anchor: true, + /// Disallow note markers hanging into the whitespace. + /// - If ```typc auto```, acts like ```typc false``` if @notefigure.anchor-numbering is ```typc auto```. + /// -> auto | bool + flush-numbering: auto, + /// Which side to place the note. + /// ```typc auto``` defaults to ```typc "outer"```. + /// In non-book documents, ```typc "outer"```/```typc "inner"``` are equivalent to ```typc "right"```/```typc "left"``` respectively. + /// -> auto | "outer" | "inner" | "left" | "right" | "near" + side: auto, + /// Vertical alignment of the notefigure. + /// #let notefigure = notefigure.with(shift: "ignore", show-caption: (number, caption) => block(outset: (left: 5cm), width: 100%, fill: oklch(70%, 0.1, 120deg, 20%), { + /// number; caption.supplement; [ ]; caption.counter.display(caption.numbering); caption.separator; caption.body + /// })) + /// - ```typc "top"```, ```typc "bottom"``` work the same as @note.alignment. + /// - ```typc "baseline"``` aligns the first baseline of the _caption_ with the main text baseline. + /// #notefigure(rect(width: 100%, height: 2pt, stroke: 0.5pt + gray), alignment: "baseline", caption: [Baseline]) + /// - ```typc "caption-top"``` aligns the top of the caption with the main text baseline. + /// #notefigure(rect(width: 100%, height: 2pt, stroke: 0.5pt + gray), alignment: "caption-top", caption: [Caption-top]) + /// + /// -> "baseline" | "top" | "bottom" | "caption-top" + alignment: "baseline", + /// Inital vertical offset of the notefigure, relative to the alignment point. + /// + /// The notefigure may get shifted still to avoid other notes depending on ```typc notefigure.shift```. + /// -> length + dy: 0pt, + /// -> bool + keep-order: false, + /// -> bool | auto | "avoid" | "ignore" + shift: auto, + /// Will be used to ```typc set``` the text style. + /// -> dictionary + text-style: (size: 9.35pt, style: "normal", weight: "regular"), + /// Will be used to ```typc set``` the par style. + /// -> dictionary + par-style: (spacing: 1.2em, leading: 0.5em, hanging-indent: 0pt), + /// Will be passed to the `block` containing the note body (this contains the entire figure). + /// If this is a function, it will be called with ```typc "left"``` or ```typc "right"``` as its argument, and the result is passed to the `block`. + /// -> dictionary | function + block-style: (width: 100%), + /// A function with two arguments, the (note-)number and the caption. + /// Will be called as the caption show rule. + /// + /// If @notefigure.numbering is ```typc none```, `number` will be ```typc none```. + /// -> function + show-caption: (number, caption) => { + number + caption.supplement + [ ] + caption.counter.display(caption.numbering) + caption.separator + caption.body + }, + /// Pass-through to ```typ #figure()```, but used to adjust the vertical position. + /// -> length + gap: 0.55em, + /// A label to attach to the note. Referencing this label will repeat the anchor, + /// so it is only really useful if @notefigure.anchor-numbering is not ```typc none```. + /// -> none | label + note-label: none, + /// The figure content, e.g.~an image. Pass-through to ```typ #figure()```, but used to adjust the vertical position. + /// -> content + content, + /// Pass-through to ```typ #figure()```. + /// + /// (E.g. `caption`) + /// -> arguments + ..figureargs, +) = { + [#metadata("_marginalia_notefigure")<_marginalia_notefigure>] + + let numbering = if counter == none { none } else { numbering } + if numbering != none { counter.step() } + let flush-numbering = if flush-numbering == auto { anchor-numbering != auto } else { flush-numbering } + let anchor-numbering = if anchor-numbering == auto { numbering } else { anchor-numbering } + + let shift = if shift == auto { if numbering != none { true } else { "avoid" } } else { shift } + + let text-style = (size: 9.35pt, style: "normal", weight: "regular", ..text-style) + let par-style = (spacing: 1.2em, leading: 0.5em, hanging-indent: 0pt, ..par-style) + + context { + let number = if counter != none and numbering != none { + if link-anchor { + show link: it => { + show underline: i => i.body + it + } + link(here(), counter.display(numbering)) + } else { + counter.display(numbering) + } + } else { none } + let number-width = if numbering != none and not flush-numbering { + let width = measure({ + set text(..text-style) + set par(..par-style) + number + }).width + if width < 8pt { 8pt } else { width } + } else { 0pt } + + set figure.caption(position: bottom) + show figure.caption: it => { + set align(left) + if numbering != none { + context if flush-numbering { + show-caption( + number, + it, + ) + } else { + show-caption( + place( + // top + left, + left, + dx: -number-width, + box( + width: number-width, + { + h(1fr) + sym.zws + number + h(1fr) + }, + ), + ), + it, + ) + } + } else { + context show-caption(none, it) + } + } + + let side = if side == "outer" or side == auto { + if _config.get().book and calc.even(here().page()) { "left" } else { "right" } + } else if side == "inner" { + if _config.get().book and calc.even(here().page()) { "right" } else { "left" } + } else if side == "near" { + _get-near-side() + } else { + side + } + + let width = if side == "left" { + get-left().width + } else { + get-right().width + } + let height = ( + measure( + width: width, + { + set text(..text-style) + set par(..par-style) + content + }, + ).height + + measure(text(..text-style, v(gap))).height + ) + let baseline-height = measure(text(..text-style, sym.zws)).height + let alignment = alignment + let dy = dy + if alignment == "baseline" { + alignment = "top" + dy = dy - height - baseline-height + } else if alignment == "caption-top" { + alignment = "top" + dy = dy - height + } + + let index = query(selector(<_marginalia_notefigure>).before(here())).len() + let figure-label = std.label("_marginalia_notefigure__" + str(index)) + + [#note( + numbering: none, + anchor-numbering: anchor-numbering, + link-anchor: link-anchor, + counter: counter, + side: side, + dy: dy, + alignment: alignment, + keep-order: keep-order, + shift: shift, + text-style: text-style, + par-style: par-style, + block-style: block-style, + [#figure( + content, + gap: gap, + placement: none, + ..figureargs, + )#figure-label], + )#note-label] + // for unclear reasons, if this is placed before the note, it becomes part of the content referenced by note-label. + [#metadata((label: figure-label))<_marginalia_notefigure_meta>] + } +} + +/// Creates a block that extends into the outside/right margin. +/// +/// Note: This does not handle page-breaks sensibly. +/// If ```typc config.book = false```, this is not a problem, as then the margins on all pages are the same. +/// However, when using alternating page margins, a multi-page `wideblock` will not work properly. +/// To be able to set this appendix in a many-page wideblock, this code was used: +/// ```typ +/// #show: marginalia.setup.with(..config, book: false) +/// #wideblock(side: "inner")[...] +/// ``` +/// +/// #compat(( +/// "0.1.5": ( +/// [`reverse` and `double` have been replaced with @wideblock.side. +/// #ergo[use \````typc side: "inner"```\` instead of \````typc reverse: true```\`] +/// #ergo[use \````typc side: "both"```\` instead of \````typc double: true```\`]], +/// ), +/// )) +/// -> content +#let wideblock( + /// Which side to extend into. + /// ```typc auto``` defaults to ```typc "outer"```. + /// In non-book documents, ```typc "outer"```/```typc "inner"``` are equivalent to ```typc "right"```/```typc "left"``` respectively. + /// -> auto | "outer" | "inner" | "left" | "right" | "both" + side: auto, + /// -> content + body, +) = ( + context { + let left-margin = get-left() + let right-margin = get-right() + + let side = if side == "outer" or side == auto { + if _config.get().book and calc.even(here().page()) { "left" } else { "right" } + } else if side == "inner" { + if _config.get().book and calc.even(here().page()) { "right" } else { "left" } + } else { side } + + assert( + side == "left" or side == "right" or side == "both", + message: "side must be auto, both, left, right, outer, or inner.", + ) + + let left = if side == "both" or side == "left" { + left-margin.width + left-margin.sep + } else { + 0pt + } + let right = if side == "both" or side == "right" { + right-margin.width + right-margin.sep + } else { + 0pt + } + + let position = here().position().y + let page_num = str(here().page()) + let pagewidth = if page.flipped { page.height } else { page.width } + let linewidth = ( + pagewidth + - left-margin.far + - left-margin.width + - left-margin.sep + - right-margin.far + - right-margin.width + - right-margin.sep + ) + let height = measure(width: linewidth + left + right, body).height + + if left != 0pt { + let current = _note_extends_left.get().at(page_num, default: ()) + let index = current.len() + _note_extends_left.update(old => { + let oldpage = old.at(page_num, default: ()) + oldpage.push((natural: position, height: height, shift: false, keep-order: false)) + old.insert(page_num, oldpage) + old + }) + } + + if right != 0pt { + let current = _note_extends_right.get().at(page_num, default: ()) + let index = current.len() + _note_extends_right.update(old => { + let oldpage = old.at(page_num, default: ()) + oldpage.push((natural: position, height: height, shift: false, keep-order: false)) + old.insert(page_num, oldpage) + old + }) + } + + pad(left: -left, right: -right, body) + } +) + + +// #let header( +// /// Will be used to ```typc set``` the text style. +// /// -> dictionary +// text-style: (:), +// /// -> optional | (content, content, content) +// even: (none, none, none), +// /// -> optional | (content, content, content) +// odd: (none, none, none), +// /// -> optional | content +// inner, +// /// -> optional | content +// center, +// /// -> optional | content +// outer, +// ) = {} + +/// This generates a @wideblock and divides its arguments into three boxes sized to match the margin setup. +/// -> content +#let header( + /// Will be used to ```typc set``` the text style. + /// -> dictionary + text-style: (:), + /// Up to three positional arguments. + /// They are interpreted as `⟨outer⟩`, `⟨center⟩⟨outer⟩`, or `⟨inner⟩⟨center⟩⟨outer⟩`, + /// depending on how many there are. + ..args, + /// This is ignored if there are positional parameters or if @setup.book is ```typc false```. + /// + /// Otherwise, it is interpreted as `(⟨outer⟩, ⟨center⟩, ⟨inner⟩)` on even pages. + /// -> array + even: (), + /// This is ignored if there are positional parameters. + /// + /// Otherwise, it is interpreted as `(⟨inner⟩, ⟨center⟩, ⟨outer⟩)` on odd pages or, if @setup.book is ```typc false```, on all pages. + /// -> array + odd: (), +) = context { + let leftm = get-left() + let rightm = get-right() + let is-odd = not _config.get().book or calc.odd(here().page()) + + set text(..text-style) + + let pos = args.pos() + if pos.len() > 0 { + // if args.named().len() > 0 { panic("cannot have named and positional arguments") } + if pos.len() == 1 { + pos = (none, none, ..pos) + } else if pos.len() == 2 { + pos = (none, ..pos) + } + let (inner, center, outer) = pos + wideblock( + side: "both", + { + box(width: leftm.width, if is-odd { inner } else { outer }) + h(leftm.sep) + box(width: 1fr, center) + h(rightm.sep) + box(width: rightm.width, if is-odd { outer } else { inner }) + }, + ) + } else { + wideblock( + side: "both", + { + box( + width: leftm.width, + if is-odd { + odd.at(0, default: none) + } else { + even.at(0, default: none) + }, + ) + h(leftm.sep) + box( + width: 1fr, + if is-odd { + odd.at(1, default: none) + } else { + even.at(1, default: none) + }, + ) + h(rightm.sep) + box( + width: rightm.width, + if is-odd { + odd.at(2, default: none) + } else { + even.at(2, default: none) + }, + ) + }, + ) + } +} diff --git a/src/resources/formats/typst/packages/preview/marginalia/0.3.1/typst.toml b/src/resources/formats/typst/packages/preview/marginalia/0.3.1/typst.toml new file mode 100644 index 00000000000..08c514bc58a --- /dev/null +++ b/src/resources/formats/typst/packages/preview/marginalia/0.3.1/typst.toml @@ -0,0 +1,22 @@ +[package] +name = "marginalia" +version = "0.3.1" +entrypoint = "lib.typ" +authors = ["nleanba <@nleanba>"] +license = "Unlicense" +description = "Configurable margin-notes with smart positioning and matching wide-blocks." +repository = "https://github.com/nleanba/typst-marginalia" +keywords = [ + "margins", + "notes", + "annotations", + "comments", + "marginnote", + "sidenote", + "tufte", + "positioning", + "layout", +] +categories = ["layout", "utility"] +compiler = "0.12.0" +exclude = ["main.typ", "Marginalia.pdf", "preview.svg"] diff --git a/src/resources/formats/typst/pandoc/quarto/biblio.typ b/src/resources/formats/typst/pandoc/quarto/biblio.typ index 49ce4c99c2d..2c8f0ebae8a 100644 --- a/src/resources/formats/typst/pandoc/quarto/biblio.typ +++ b/src/resources/formats/typst/pandoc/quarto/biblio.typ @@ -1,12 +1,17 @@ $if(citations)$ -$if(csl)$ - -#set bibliography(style: "$csl$") -$elseif(bibliographystyle)$ +$if(csl)$ + +#set bibliography(style: "$csl$") +$elseif(bibliographystyle)$ #set bibliography(style: "$bibliographystyle$") $endif$ $if(bibliography)$ +$-- Suppress bibliography display when citation-location: margin (consistent with HTML behavior) +$-- Full citations appear in margins; bibliography is loaded but not displayed +$if(suppress-bibliography)$ +#show bibliography: none +$endif$ #bibliography(($for(bibliography)$"$bibliography$"$sep$,$endfor$)) $endif$ diff --git a/src/resources/formats/typst/pandoc/quarto/page.typ b/src/resources/formats/typst/pandoc/quarto/page.typ index a0289428606..4e0c825add1 100644 --- a/src/resources/formats/typst/pandoc/quarto/page.typ +++ b/src/resources/formats/typst/pandoc/quarto/page.typ @@ -1,9 +1,75 @@ #set page( paper: $if(papersize)$"$papersize$"$else$"us-letter"$endif$, - margin: $if(margin)$($for(margin/pairs)$$margin.key$: $margin.value$,$endfor$)$else$(x: 1.25in, y: 1.25in)$endif$, +$if(margin-layout)$ + // Margins handled by marginalia.setup below +$elseif(margin)$ + margin: ($for(margin/pairs)$$margin.key$: $margin.value$,$endfor$), +$else$ + margin: (x: 1.25in, y: 1.25in), +$endif$ numbering: $if(page-numbering)$"$page-numbering$"$else$none$endif$, columns: $if(columns)$$columns$$else$1$endif$, ) $if(logo)$ #set page(background: align($logo.location$, box(inset: $logo.inset$, image("$logo.path$", width: $logo.width$$if(logo.alt)$, alt: "$logo.alt$"$endif$)))) $endif$ +$if(margin-layout)$ + +#import "@preview/marginalia:0.3.1" as marginalia: note, notefigure, wideblock + +#show: marginalia.setup.with( + inner: ( + far: 0in, + width: 0in, + sep: $margin-geometry.inner-sep$, + ), + outer: ( + far: $margin-geometry.outer-far$, + width: $margin-geometry.outer-width$, + sep: $margin-geometry.outer-sep$, + ), + top: $if(margin.top)$$margin.top$$else$1.25in$endif$, + bottom: $if(margin.bottom)$$margin.bottom$$else$1.25in$endif$, + book: false, + clearance: 8pt, +) + +// Render footnote as margin note using standard footnote counter +// This is consistent with LaTeX's sidenotes package behavior +#let quarto-sidenote(body) = { + counter(footnote).step() + context { + let num = counter(footnote).display("1") + // Superscript mark in text + super(num) + // Content in margin with matching number + note( + alignment: "baseline", + shift: auto, + counter: none, // We display our own number from footnote counter + )[ + #super(num) #body + ] + } +} + +// Margin citation - inline citation + full bibliographic entry in margin +// Each citation key gets the inline citation, with full entry appearing in margin +#let quarto-margin-cite(..labels) = { + // Render inline citations + for label in labels.pos() { + cite(label) + } + // Full citation entries in margin + note( + alignment: "baseline", + shift: auto, + counter: none, + )[ + #set text(size: 0.85em) + #for label in labels.pos() [ + #cite(label, form: "full") + ] + ] +} +$endif$ diff --git a/src/resources/schema/cell-pagelayout.yml b/src/resources/schema/cell-pagelayout.yml index fe41741ead4..415b5147044 100644 --- a/src/resources/schema/cell-pagelayout.yml +++ b/src/resources/schema/cell-pagelayout.yml @@ -22,7 +22,7 @@ - name: cap-location tags: contexts: [document-layout] - formats: [$html-files, $pdf-all] + formats: [$html-files, $pdf-all, typst] schema: enum: [top, bottom, margin] default: bottom @@ -31,7 +31,7 @@ - name: fig-cap-location tags: contexts: [document-layout, document-figures] - formats: [$html-files, $pdf-all] + formats: [$html-files, $pdf-all, typst] schema: enum: [top, bottom, margin] default: bottom @@ -40,7 +40,7 @@ - name: tbl-cap-location tags: contexts: [document-layout, document-tables] - formats: [$html-files, $pdf-all] + formats: [$html-files, $pdf-all, typst] schema: enum: [top, bottom, margin] default: top diff --git a/src/resources/schema/document-footnotes.yml b/src/resources/schema/document-footnotes.yml index 98f8631199e..fd1b0d855a5 100644 --- a/src/resources/schema/document-footnotes.yml +++ b/src/resources/schema/document-footnotes.yml @@ -14,7 +14,7 @@ - name: reference-location tags: - formats: [$markdown-all, muse, $html-files, pdf] + formats: [$markdown-all, muse, $html-files, pdf, typst] schema: enum: [block, section, margin, document] default: document diff --git a/src/resources/schema/document-layout.yml b/src/resources/schema/document-layout.yml index fe33f021ae1..eb5ced43477 100644 --- a/src/resources/schema/document-layout.yml +++ b/src/resources/schema/document-layout.yml @@ -90,6 +90,8 @@ docx and odt (8.5 inches with 1 inch for each margins). - name: grid + tags: + formats: [$html-doc, typst] schema: object: closed: true @@ -102,15 +104,15 @@ description: "The base width of the sidebar (left) column in an HTML page." margin-width: string: - description: "The base width of the margin (right) column in an HTML page." + description: "The base width of the margin (right) column. For Typst, this controls the width of the margin note column." body-width: string: - description: "The base width of the body (center) column in an HTML page." + description: "The base width of the body (center) column. For Typst, this is computed as the remainder after other columns." gutter-width: string: - description: "The width of the gutter that appears between columns in an HTML page." + description: "The width of the gutter that appears between columns. For Typst, this is the gap between the text column and margin notes." description: - short: "Properties of the grid system used to layout Quarto HTML pages." + short: "Properties of the grid system used to layout Quarto HTML and Typst pages." - name: appendix-style schema: diff --git a/src/resources/schema/document-references.yml b/src/resources/schema/document-references.yml index 030f9b33856..0790b27bed0 100644 --- a/src/resources/schema/document-references.yml +++ b/src/resources/schema/document-references.yml @@ -19,7 +19,7 @@ schema: enum: [document, margin] tags: - formats: [$html-doc] + formats: [$html-doc, typst] default: document description: Where citation information should be displayed (`document` or `margin`) diff --git a/tests/docs/render/typst-package-staging/.gitignore b/tests/docs/render/typst-package-staging/.gitignore new file mode 100644 index 00000000000..8c00f5eac16 --- /dev/null +++ b/tests/docs/render/typst-package-staging/.gitignore @@ -0,0 +1,4 @@ +/.quarto/ +**/*.quarto_ipynb +test.pdf +test.typ diff --git a/tests/docs/render/typst-package-staging/_extensions/test-ext/_extension.yml b/tests/docs/render/typst-package-staging/_extensions/test-ext/_extension.yml new file mode 100644 index 00000000000..acd412a1c46 --- /dev/null +++ b/tests/docs/render/typst-package-staging/_extensions/test-ext/_extension.yml @@ -0,0 +1,9 @@ +title: Test Extension +author: Test +version: 1.0.0 +quarto-required: ">=1.6.0" +contributes: + formats: + typst: + template-partials: + - typst-show.typ diff --git a/tests/docs/render/typst-package-staging/_extensions/test-ext/typst-show.typ b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst-show.typ new file mode 100644 index 00000000000..c7d70d5b752 --- /dev/null +++ b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst-show.typ @@ -0,0 +1,11 @@ +// Import packages from extension's typst/packages/ +#import "@preview/hello:0.1.0": greet +#import "@local/confetti:0.1.0": celebrate + +// Apply standard article show rule +#show: doc => article( + $if(title)$ + title: [$title$], + $endif$ + doc, +) diff --git a/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/local/confetti/0.1.0/lib.typ b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/local/confetti/0.1.0/lib.typ new file mode 100644 index 00000000000..e9396829b14 --- /dev/null +++ b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/local/confetti/0.1.0/lib.typ @@ -0,0 +1 @@ +#let celebrate(occasion) = [🎉 #occasion 🎊] diff --git a/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/local/confetti/0.1.0/typst.toml b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/local/confetti/0.1.0/typst.toml new file mode 100644 index 00000000000..109390d1769 --- /dev/null +++ b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/local/confetti/0.1.0/typst.toml @@ -0,0 +1,4 @@ +[package] +name = "confetti" +version = "0.1.0" +entrypoint = "lib.typ" diff --git a/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/preview/hello/0.1.0/lib.typ b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/preview/hello/0.1.0/lib.typ new file mode 100644 index 00000000000..e51da92d97a --- /dev/null +++ b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/preview/hello/0.1.0/lib.typ @@ -0,0 +1 @@ +#let greet(name) = [Hello, #name!] diff --git a/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/preview/hello/0.1.0/typst.toml b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/preview/hello/0.1.0/typst.toml new file mode 100644 index 00000000000..e0f708f4a74 --- /dev/null +++ b/tests/docs/render/typst-package-staging/_extensions/test-ext/typst/packages/preview/hello/0.1.0/typst.toml @@ -0,0 +1,4 @@ +[package] +name = "hello" +version = "0.1.0" +entrypoint = "lib.typ" diff --git a/tests/docs/render/typst-package-staging/_quarto.yml b/tests/docs/render/typst-package-staging/_quarto.yml new file mode 100644 index 00000000000..9524e4548a1 --- /dev/null +++ b/tests/docs/render/typst-package-staging/_quarto.yml @@ -0,0 +1,2 @@ +project: + title: "Typst Package Staging Test" diff --git a/tests/docs/render/typst-package-staging/test.qmd b/tests/docs/render/typst-package-staging/test.qmd new file mode 100644 index 00000000000..a0f0256042a --- /dev/null +++ b/tests/docs/render/typst-package-staging/test.qmd @@ -0,0 +1,27 @@ +--- +title: "Typst Package Staging Test" +papersize: us-letter +format: test-ext-typst +keep-typ: true +--- + +This tests that typst packages from extensions are staged to `.quarto/typst-packages/`. + +::: {.column-margin} +This margin note triggers the marginalia package. + +| 3 | 1 | 4 | +|---|---|---| +| 1 | 5 | 9 | +| 2 | 6 | 5 | + +: Numbers 1-9 (some repeated) +::: + +The extension's template imports `@preview/hello:0.1.0` and `@local/confetti` which are bundled in the extension's `typst/packages/` directory. + +```{=typst} +#greet("World") + +#celebrate("Airgap rendering works!") +``` diff --git a/tests/docs/smoke-all/article-layout/tables/tufte-typst-sidenote-orphans.qmd b/tests/docs/smoke-all/article-layout/tables/tufte-typst-sidenote-orphans.qmd new file mode 100644 index 00000000000..f180f04ed05 --- /dev/null +++ b/tests/docs/smoke-all/article-layout/tables/tufte-typst-sidenote-orphans.qmd @@ -0,0 +1,191 @@ +--- +title: "Typst Sidenote Orphan Detection Test" +subtitle: "Tests that margin captions stay with their figures" +format: + typst: + keep-typ: true + papersize: us-letter +# This test verifies that margin captions don't get orphaned from their figures +# across page breaks. The test uses Typst introspection to embed page numbers +# directly in captions, making orphans visible: (CAPTION-PG:2 FIGURE-PG:3) would +# mean the caption is on page 2 but the figure moved to page 3. +# +# The fix wraps margin caption + figure in block(breakable: false) in the +# generated Typst output (see make_typst_margin_caption_figure in layout/typst.lua). +_quarto: + tests: + typst: + ensurePdfRegexMatches: + - [] # nothing required to match + # FAIL if caption and figure are on different pages (orphan detected) + # Regex matches CAPTION-PG:X followed by FIGURE-PG:Y where Y != X + - ['CAPTION-PG:(\d+) FIGURE-PG:(?!\1)\d+'] + noErrors: default +--- + +# Sidenote Orphan Test + +This test verifies that margin captions stay on the same page as their figures. +Each caption displays its page number alongside the figure's page number using +Typst introspection. If these numbers differ, the caption has been orphaned. + +**Expected behavior**: All captions should show matching page numbers like +`(CAPTION-PG:N FIGURE-PG:N)` where both N values are the same. + +**Bug behavior**: Orphaned captions show mismatched pages like +`(CAPTION-PG:X FIGURE-PG:Y)` where X and Y differ - the caption is stranded. + +## Test Case 1: Simple Margin Caption + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +::: {#fig-test1 fig-cap-location="margin"} + +```{=typst} +#rect(width: 100%, height: 2.5in, fill: luma(200), stroke: 1pt)[ + #align(center + horizon)[Figure 1 Content Area] +] +``` + +A figure with margin caption. This tests basic orphan prevention. +`#context { let f = query().first().location().page(); let c = here().page(); [(CAPTION-PG:#c FIGURE-PG:#f)] }`{=typst} + +::: + +More content after the first figure to continue the document flow. + +## Test Case 2: Larger Figure Near Page Break + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +::: {#fig-test2 fig-cap-location="margin"} + +```{=typst} +#rect(width: 100%, height: 3in, fill: luma(180), stroke: 1pt)[ + #align(center + horizon)[Figure 2 Content Area - Taller] +] +``` + +A taller figure that is more likely to cause orphaning when positioned near a +page boundary. The caption should stay with the figure. +`#context { let f = query().first().location().page(); let c = here().page(); [(CAPTION-PG:#c FIGURE-PG:#f)] }`{=typst} + +::: + +Content continues after figure 2. + +## Test Case 3: Multiple Figures in Sequence + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis +nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore +eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt +in culpa qui officia deserunt mollit anim id est laborum. + +::: {#fig-test3a fig-cap-location="margin"} + +```{=typst} +#rect(width: 100%, height: 2in, fill: luma(220), stroke: 1pt)[ + #align(center + horizon)[Figure 3a Content] +] +``` + +First of two sequential figures with margin captions. +`#context { let f = query().first().location().page(); let c = here().page(); [(CAPTION-PG:#c FIGURE-PG:#f)] }`{=typst} + +::: + +Brief text between figures. + +::: {#fig-test3b fig-cap-location="margin"} + +```{=typst} +#rect(width: 100%, height: 2in, fill: luma(160), stroke: 1pt)[ + #align(center + horizon)[Figure 3b Content] +] +``` + +Second sequential figure. Both captions should stay with their figures. +`#context { let f = query().first().location().page(); let c = here().page(); [(CAPTION-PG:#c FIGURE-PG:#f)] }`{=typst} + +::: + +## Conclusion + +If all `CAPTION-PG` and `FIGURE-PG` values match within each figure, the +orphan prevention is working correctly. If any pair shows different page +numbers, the margin caption has been orphaned from its figure. diff --git a/tests/docs/smoke-all/typst/margin-layout/aside.qmd b/tests/docs/smoke-all/typst/margin-layout/aside.qmd new file mode 100644 index 00000000000..ac616d7f5c5 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/aside.qmd @@ -0,0 +1,25 @@ +--- +title: "Aside Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#note\(alignment: "baseline"'] + - [] + ensurePdfTextPositions: + - - subject: "This aside" + relation: rightOf + object: "Main content" + - [] + noErrors: default +--- + +::: {.aside} +This aside should appear in the margin. +::: + +Main content here. diff --git a/tests/docs/smoke-all/typst/margin-layout/basic-margin-note.qmd b/tests/docs/smoke-all/typst/margin-layout/basic-margin-note.qmd new file mode 100644 index 00000000000..0e3ab4ad11e --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/basic-margin-note.qmd @@ -0,0 +1,25 @@ +--- +title: "Typst Margin Layout Foundation Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['@preview/marginalia:0\.3\.1', 'marginalia\.setup\.with\('] + - [] + ensurePdfTextPositions: + - - subject: "This triggers" + relation: rightOf + object: "Main content" + - [] + noErrors: default +--- + +::: {.column-margin} +This triggers margin layout. +::: + +Main content here. diff --git a/tests/docs/smoke-all/typst/margin-layout/borges-refs.bib b/tests/docs/smoke-all/typst/margin-layout/borges-refs.bib new file mode 100644 index 00000000000..3ae36d26d6a --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/borges-refs.bib @@ -0,0 +1,138 @@ +% Borgesian Bibliography: On the Library and Its Commentators +% A mixture of the real, the apocryphal, and the impossible + +@incollection{borges1941library, + author = {Borges, Jorge Luis}, + title = {La biblioteca de Babel}, + booktitle = {El jard{\'i}n de senderos que se bifurcan}, + publisher = {Editorial Sur}, + year = {1941}, + address = {Buenos Aires}, + note = {First appearance of the infinite library} +} + +@book{borges1944ficciones, + author = {Borges, Jorge Luis}, + title = {Ficciones}, + publisher = {Editorial Sur}, + year = {1944}, + address = {Buenos Aires}, + note = {Contains both "The Library of Babel" and "Tl{\"o}n, Uqbar, Orbis Tertius"} +} + +@article{kurd1933hexagonal, + author = {Kurd Lasswitz}, + title = {{Die Universalbibliothek}}, + journal = {Traumkristalle}, + year = {1904}, + note = {German precursor describing a universal library of all possible books}, + address = {Leipzig} +} + +@unpublished{menard1934quixote, + author = {Pierre Menard}, + title = {El Quijote}, + year = {1934}, + note = {Fragment. Chapters IX and XXXVIII of Part One, and a fragment of Chapter XXII. Word-for-word identical to Cervantes yet infinitely richer} +} + +@book{nemo1897catalogue, + author = {Captain Nemo}, + title = {Catalogue of the Nautilus Library}, + publisher = {Submarine Press}, + year = {1897}, + address = {20,000 Leagues Beneath the Sea}, + note = {12,000 volumes. No works published after 1865} +} + +@article{funes1897memory, + author = {Ireneo Funes}, + title = {Sistema original de numeraci{\'o}n}, + journal = {Unpublished manuscript, Fray Bentos}, + year = {1897}, + note = {Each number given a unique name. The author could recall every leaf on every tree he had ever seen} +} + +@book{quain1936labyrinth, + author = {Herbert Quain}, + title = {April March}, + publisher = {Siamese Press}, + year = {1936}, + address = {London}, + note = {A novel with thirteen chapters that must be read backwards. Nine possible endings} +} + +@phdthesis{tzinacana1521jaguar, + author = {Tzinac{\'a}n}, + title = {The Writing of the God}, + school = {Temple of Qaholom}, + year = {1521}, + note = {Fourteen words that make their speaker omnipotent. Written on the skin of a jaguar} +} + +@misc{librarians1638first, + author = {{The First Librarian of the Third Hexagon}}, + title = {On the Total Library}, + year = {1638}, + howpublished = {Oral tradition}, + note = {Asserted that the Library contains all books, including the faithful catalogue of the Library and the demonstration that this catalogue is false} +} + +@article{soergel1638index, + author = {Soergel, Johann Valentin}, + title = {{Index Librorum Omnium}}, + journal = {Hexagon Quarterly}, + year = {1638}, + volume = {MCCXIV}, + pages = {1--410}, + note = {Purported to be the index of all books. Later discovered to be the index of all indexes} +} + +@book{ts1001nights, + author = {{Scheherazade}}, + title = {The Thousand and One Nights: The Tale of the Infinite Manuscript}, + publisher = {Royal Palace of King Shahryar}, + year = {1001}, + note = {Contains a story about a story that contains all stories} +} + +@book{eco1980name, + author = {Eco, Umberto}, + title = {Il nome della rosa}, + publisher = {Bompiani}, + year = {1980}, + address = {Milano}, + note = {A library that kills to protect its secrets} +} + +@article{calvino1979winter, + author = {Calvino, Italo}, + title = {Se una notte d'inverno un viaggiatore}, + journal = {Einaudi}, + year = {1979}, + note = {A novel about trying to read a novel that keeps transforming into other novels} +} + +@book{casares1940morel, + author = {Bioy Casares, Adolfo}, + title = {La invenci{\'o}n de Morel}, + publisher = {Editorial Losada}, + year = {1940}, + address = {Buenos Aires}, + note = {Prologue by Borges. Machines that record and replay reality eternally} +} + +@misc{babel2XXX, + author = {{Anonymous Hexagon Dweller}}, + title = {MCV:LXXXIII:JTK:QRZL}, + howpublished = {Shelf 17, Row 3, Hexagon 41159131621}, + note = {One of the infinitely many books consisting entirely of the letters MCV repeated. Date of composition unknown---perhaps infinite} +} + +@article{vindication1738, + author = {{Anonymous}}, + title = {Vindication}, + journal = {Some hexagon}, + year = {1738}, + note = {A book containing the vindication of every person who has ever lived. Its location is unknown. Searchers have died looking for it} +} diff --git a/tests/docs/smoke-all/typst/margin-layout/citation-margin-basic.qmd b/tests/docs/smoke-all/typst/margin-layout/citation-margin-basic.qmd new file mode 100644 index 00000000000..96d86c8e135 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/citation-margin-basic.qmd @@ -0,0 +1,41 @@ +--- +title: "On the Library of Babel" +papersize: us-letter +citation-location: margin +bibliography: borges-refs.bib +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['quarto-margin-cite', 'note\(', 'form: "full"'] + - [] + ensurePdfRegexMatches: + - ['Library', 'Borges', 'hexagon', 'infinite'] + - [] + ensurePdfTextPositions: + - # Citation in margin (rightOf body text) - "Editorial Sur" only in citation + - subject: "Editorial Sur" + relation: rightOf + object: "hexagonal galleries" + - [] + noErrors: default +--- + +# The Library of Babel + +In 1941, Jorge Luis Borges published what many consider his most perfect short story: "La biblioteca de Babel" [@borges1941library]. The premise is deceptively simple yet philosophically devastating: the universe consists of an enormous, perhaps infinite, library. + +## Structure of the Library + +The Library is composed of an indefinite, perhaps infinite, number of hexagonal galleries. Each hexagon contains exactly twenty bookshelves, five on each of four walls. Each shelf holds thirty-two books of identical format: each book has four hundred ten pages; each page, forty lines; each line, approximately eighty black letters. + +The totality of such letters comprises twenty-five orthographical symbols: the twenty-two letters of the alphabet, the comma, the period, and the space. No two books are identical. From these incontrovertible premises, the librarian deduces that the Library is total---that its shelves contain all possible combinations of the twenty-five symbols, which is to say, all that can be expressed in any language. + +## The Total Library + +The concept of a universal library predates Borges. The German author Kurd Lasswitz explored similar territory [@kurd1933hexagonal], calculating that such a library, while enormous, would be finite. Borges, characteristically, preferred the more vertiginous infinite. + +Later collected in *Ficciones* [@borges1944ficciones], the story has influenced countless writers. Umberto Eco's medieval mystery [@eco1980name] features a labyrinthine library that kills to protect its secrets. Italo Calvino [@calvino1979winter] wrote an entire novel about the impossibility of reading a single book completely. diff --git a/tests/docs/smoke-all/typst/margin-layout/citation-margin-elaborate.qmd b/tests/docs/smoke-all/typst/margin-layout/citation-margin-elaborate.qmd new file mode 100644 index 00000000000..604bc42f351 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/citation-margin-elaborate.qmd @@ -0,0 +1,61 @@ +--- +title: "The Universe (Which Others Call the Library)" +subtitle: "A Meditation on Infinite Texts and Their Margin Notes" +author: "A Hexagon Dweller" +papersize: us-letter +citation-location: margin +bibliography: borges-refs.bib +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['quarto-margin-cite', 'note\('] + - [] + ensurePdfRegexMatches: + - ['Universe', 'Library', 'hexagon', 'Vindication', 'Borges'] + - [] + ensurePdfTextPositions: + - # Citation in margin (rightOf body text) - "Editorial Sur" only in citation + - subject: "Editorial Sur" + relation: rightOf + object: "ventilation shaft" + - [] + noErrors: default +--- + +# Prolegomena + +The universe (which others call the Library) is composed of an indefinite, perhaps infinite, number of hexagonal galleries [@borges1941library]. In the center of each gallery is a ventilation shaft, bounded by a low railing. From any hexagon one can see the floors above and below---one after another, endlessly. + +This essay concerns itself not with the Library's architecture, which has been adequately described elsewhere [@borges1944ficciones], but with a peculiar feature of its books: the margin notes. + +# On Marginalia in the Total Library + +If the Library contains all possible books of 410 pages, it must contain books with margins. And if it contains books with margins, it must contain margin notes. The German philosopher Kurd Lasswitz [@kurd1933hexagonal] did not consider this recursive complication in his calculations. + +Consider: for each book in the Library, there exists another book containing the first book's text plus commentary in the margins. And for each of these annotated volumes, there exists a meta-commentary. The recursion is, like the Library itself, infinite. + +## Herbert Quain's Marginal Method + +The Anglo-Irish writer Herbert Quain [@quain1936labyrinth] reportedly composed his novels in reverse, writing the margin notes first. His masterwork, *April March*, can be read in nine different ways, each determined by which margin notes the reader chooses to follow. + +## The Margins of the Vindication + +The seekers of the Vindication [@vindication1738] have long debated: if the Vindication exists, do its margins contain additional vindications? The First Librarian [@librarians1638first] argued that the true Vindication would have no margins at all---that the text itself would fill every available space, justifying even the whiteness of the paper. + +# Literary Descendants + +Borges's influence on subsequent literature is well documented. Bioy Casares [@casares1940morel], Borges's collaborator and friend, created machines that record and replay reality. Eco [@eco1980name] built a library that murders. Calvino [@calvino1979winter] wrote about a reader unable to finish any book because each book keeps becoming another book. + +What these authors share is an understanding that texts are not stable objects but rather provisional arrangements of signs that point to other signs. This is the lesson of the Library: meaning is always deferred, always in the margins. + +# Conclusion + +In the Library, I search for the book that will explain my life. Somewhere among the twenty-five symbols lies my vindication. Until then, I write these margin notes, adding to the infinite commentary. + +As Tzinacana discovered in his prison [@tzinacana1521jaguar], sometimes the most important text is not written in books at all but inscribed on the skin of a jaguar, fourteen words that make their speaker omnipotent. But Tzinacana chose not to speak them. + +Perhaps the Library's true meaning is in the margins we choose not to read, the books we pass by in our endless search for the one book that will make sense of all the others. Perhaps meaning itself is marginal. diff --git a/tests/docs/smoke-all/typst/margin-layout/citation-margin-multiple.qmd b/tests/docs/smoke-all/typst/margin-layout/citation-margin-multiple.qmd new file mode 100644 index 00000000000..ba1761d00cf --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/citation-margin-multiple.qmd @@ -0,0 +1,41 @@ +--- +title: "Apocryphal Librarians and Impossible Catalogues" +papersize: us-letter +citation-location: margin +bibliography: borges-refs.bib +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['quarto-margin-cite'] + - [] + ensurePdfRegexMatches: + - ['FIRST-CITE-MARKER', 'SECOND-CITE-MARKER', 'MULTI-CITE-MARKER', 'Vindication'] + - [] + ensurePdfTextPositions: + - # Citation in margin (rightOf body text) - "Some hexagon" only in @vindication1738 citation + - subject: "Some hexagon" + relation: rightOf + object: "FIRST-CITE-MARKER" + - [] + noErrors: default +--- + +# The Seekers of the Vindication + +Among the infinite shelves of the Library, certain books have achieved mythical status. FIRST-CITE-MARKER The most sought-after is the Vindication [@vindication1738]---a book said to contain the complete justification of every person who has ever lived, written in clear prose. + +## The First Librarian's Paradox + +SECOND-CITE-MARKER The First Librarian of the Third Hexagon [@librarians1638first] famously observed that if the Library contains all books, it must contain both the faithful catalogue of the Library and the demonstration that this catalogue is false. Soergel's attempt at an index [@soergel1638index] proved to be merely an index of all indexes, recursively swallowing itself. + +## Fictional Precursors + +MULTI-CITE-MARKER These paradoxes echo throughout literature [@borges1941library; @eco1980name; @calvino1979winter]. The tradition of impossible books extends from Scheherazade's tales [@ts1001nights] through Pierre Menard's verbatim yet infinitely richer Quixote [@menard1934quixote]. + +## The Memory of Funes + +Not all impossible texts require a library. Ireneo Funes [@funes1897memory], after an accident that left him with perfect memory, devised a numbering system where each integer had a unique name. He could recall every leaf on every tree he had ever seen. His attempt to catalogue his memories would have required more time than the universe contains. diff --git a/tests/docs/smoke-all/typst/margin-layout/collision-avoidance.qmd b/tests/docs/smoke-all/typst/margin-layout/collision-avoidance.qmd new file mode 100644 index 00000000000..82fad412e1b --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/collision-avoidance.qmd @@ -0,0 +1,53 @@ +--- +title: "Collision Avoidance Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#note\('] + - [] + ensurePdfTextPositions: + - # All margin notes right of body + - subject: "First margin" + relation: rightOf + object: "Line 1" + # Vertical stacking chain + - subject: "First margin" + relation: above + object: "Second note" + - subject: "Second note" + relation: above + object: "Third note" + - subject: "Third note" + relation: above + object: "Fourth note" + # Line 5 should be top-aligned with its margin note (enough space) + - subject: "Line 5" + relation: topAligned + object: "This note" + - [] + noErrors: default +--- + +This tests that multiple notes on nearby lines stack properly without overlapping. + +Line 1 [First margin note with longer content that takes up multiple lines in the narrow margin column to test stacking]{.column-margin}. +Line 2 [Second note should shift down to avoid the first]{.column-margin}. +Line 3 [Third note shifts further down]{.column-margin}. +Line 4 [Fourth note continues the stack]{.column-margin}. + +## About Tufte Layout + +The Tufte layout style, named after Edward Tufte, places supplementary information in wide margins alongside the main text. This approach keeps the primary narrative clean while providing context, citations, and asides within the reader's peripheral vision. + +Tufte's books on information design—*The Visual Display of Quantitative Information*, *Envisioning Information*, and *Beautiful Evidence*—pioneered this layout for print. The wide right margin accommodates sidenotes, small figures, and citations without interrupting the flow of the main argument. + +In digital contexts, this layout translates well to screens wide enough to display both columns comfortably. For narrower screens, the margin content typically reflows into the main column or becomes expandable. + +The key advantage is reduced cognitive load: readers don't need to jump to footnotes at the page bottom or endnotes in a separate section. The relevant context appears exactly where it's needed. + +Line 5 [This note should appear near its source line since there's enough vertical space now]{.column-margin}. diff --git a/tests/docs/smoke-all/typst/margin-layout/crossref-grand-finale.qmd b/tests/docs/smoke-all/typst/margin-layout/crossref-grand-finale.qmd new file mode 100644 index 00000000000..dea987e979c --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/crossref-grand-finale.qmd @@ -0,0 +1,331 @@ +--- +title: "Cross-Reference Grand Finale" +papersize: us-letter +grid: + margin-width: 2in + gutter-width: 0.25in +format: + typst: + keep-typ: true + include-in-header: + text: | + #show figure: set block(breakable: true) +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Should have notefigure for margin content and regular figures for main + - ['#notefigure\(', '#figure\('] + - [] + ensurePdfRegexMatches: + # Verify sequential numbering throughout document + # Figures: 1-6 + - - 'Figure 1' + - 'FIG-MARGIN-ALPHA' + - 'Figure 2' + - 'FIG-MAIN-BETA' + - 'Figure 3' + - 'FIG-MARGINCAP-GAMMA' + - 'Figure 4' + - 'FIG-MARGIN-DELTA' + - 'Figure 5' + - 'FIG-MAIN-EPSILON' + - 'Figure 6' + - 'FIG-MARGINCAP-ZETA' + # Tables: 1-6 + - 'Table 1' + - 'TBL-MARGIN-ALPHA' + - 'Table 2' + - 'TBL-MAIN-BETA' + - 'Table 3' + - 'TBL-MARGINCAP-GAMMA' + - 'Table 4' + - 'TBL-MARGIN-DELTA' + - 'Table 5' + - 'TBL-MAIN-EPSILON' + - 'Table 6' + - 'TBL-MARGINCAP-ZETA' + # Listings: 1-6 + - 'Listing 1' + - 'LST-MARGIN-ALPHA' + - 'Listing 2' + - 'LST-MAIN-BETA' + - 'Listing 3' + - 'LST-MARGINCAP-GAMMA' + - 'Listing 4' + - 'LST-MARGIN-DELTA' + - 'Listing 5' + - 'LST-MAIN-EPSILON' + - 'Listing 6' + - 'LST-MARGINCAP-ZETA' + # Equations: 1-6 + - 'Equation 1' + - 'Equation 2' + - 'Equation 3' + - 'Equation 4' + - 'Equation 5' + - 'Equation 6' + - [] + ensurePdfTextPositions: + - # Margin figure caption in margin (rightOf body text) - "Figure 1:" unique to caption + - subject: "Figure 1:" + relation: rightOf + object: "Round 1" + # Margin table caption in margin + - subject: "Table 1:" + relation: rightOf + object: "Round 1" + # Margin listing caption in margin + - subject: "Listing 1:" + relation: rightOf + object: "Round 1" + # Margin caption figure in margin + - subject: "Figure 3:" + relation: rightOf + object: "Round 3" + - [] + noErrors: default +--- + +This comprehensive test verifies cross-reference numbering across figures, tables, listings, and equations placed in different layout positions. + +## Round 1: Margin Placement + +### Figure in Margin + +::: {.column-margin} +![FIG-MARGIN-ALPHA](https://placehold.co/150x100/blue/white?text=Fig1){#fig-margin-alpha} +::: + +Reference to @fig-margin-alpha (Figure 1, caption FIG-MARGIN-ALPHA). + +### Table in Margin + +::: {.column-margin} + +| X | Y | +|---|---| +| 1 | 2 | + +: TBL-MARGIN-ALPHA {#tbl-margin-alpha} + +::: + +Reference to @tbl-margin-alpha (Table 1, caption TBL-MARGIN-ALPHA). + +### Listing in Margin + +::: {.column-margin} + +```{#lst-margin-alpha .python lst-cap="LST-MARGIN-ALPHA"} +x = 1 # margin listing 1 +``` + +::: + +Reference to @lst-margin-alpha (Listing 1, caption LST-MARGIN-ALPHA). + +### Equation in Margin + +::: {.column-margin} +$$ +\alpha = 1 +$$ {#eq-margin-alpha} +::: + +Reference to @eq-margin-alpha (Equation 1). + +## Round 2: Main Column Placement + +### Figure in Main + +![FIG-MAIN-BETA](https://placehold.co/400x200/green/white?text=Fig2){#fig-main-beta} + +Reference to @fig-main-beta (Figure 2, caption FIG-MAIN-BETA). + +### Table in Main + +| A | B | C | +|---|---|---| +| 1 | 2 | 3 | +| 4 | 5 | 6 | + +: TBL-MAIN-BETA {#tbl-main-beta} + +Reference to @tbl-main-beta (Table 2, caption TBL-MAIN-BETA). + +### Listing in Main + +```{#lst-main-beta .python lst-cap="LST-MAIN-BETA"} +y = 2 # main listing 2 +``` + +Reference to @lst-main-beta (Listing 2, caption LST-MAIN-BETA). + +### Equation in Main + +$$ +\beta = 2 +$$ {#eq-main-beta} + +Reference to @eq-main-beta (Equation 2). + +## Round 3: Main Content with Margin Caption + +### Figure with Margin Caption + +![FIG-MARGINCAP-GAMMA](https://placehold.co/400x200/red/white?text=Fig3){#fig-margincap-gamma .margin-caption} + +Reference to @fig-margincap-gamma (Figure 3, caption FIG-MARGINCAP-GAMMA in margin). + +### Table with Margin Caption + +| P | Q | R | +|---|---|---| +| a | b | c | + +: TBL-MARGINCAP-GAMMA {#tbl-margincap-gamma .margin-caption} + +Reference to @tbl-margincap-gamma (Table 3, caption TBL-MARGINCAP-GAMMA in margin). + +### Listing with Margin Caption + +```{#lst-margincap-gamma .python lst-cap="LST-MARGINCAP-GAMMA" .margin-caption} +z = 3 # main listing with margin caption +``` + +Reference to @lst-margincap-gamma (Listing 3, caption LST-MARGINCAP-GAMMA in margin). + +### Equation in Main (equations don't have separate captions) + +$$ +\gamma = 3 +$$ {#eq-main-gamma} + +Reference to @eq-main-gamma (Equation 3). + +## Round 4: More Margin Placement + +### Figure in Margin + +::: {.column-margin} +![FIG-MARGIN-DELTA](https://placehold.co/150x100/purple/white?text=Fig4){#fig-margin-delta} +::: + +Reference to @fig-margin-delta (Figure 4, caption FIG-MARGIN-DELTA). + +### Table in Margin + +::: {.column-margin} + +| M | N | +|---|---| +| 7 | 8 | + +: TBL-MARGIN-DELTA {#tbl-margin-delta} + +::: + +Reference to @tbl-margin-delta (Table 4, caption TBL-MARGIN-DELTA). + +### Listing in Margin + +::: {.column-margin} + +```{#lst-margin-delta .python lst-cap="LST-MARGIN-DELTA"} +a = 4 # margin listing 4 +``` + +::: + +Reference to @lst-margin-delta (Listing 4, caption LST-MARGIN-DELTA). + +### Equation in Margin + +::: {.column-margin} +$$ +\delta = 4 +$$ {#eq-margin-delta} +::: + +Reference to @eq-margin-delta (Equation 4). + +## Round 5: More Main Column + +### Figure in Main + +![FIG-MAIN-EPSILON](https://placehold.co/400x200/orange/white?text=Fig5){#fig-main-epsilon} + +Reference to @fig-main-epsilon (Figure 5, caption FIG-MAIN-EPSILON). + +### Table in Main + +| D | E | F | +|---|---|---| +| 9 | 0 | 1 | + +: TBL-MAIN-EPSILON {#tbl-main-epsilon} + +Reference to @tbl-main-epsilon (Table 5, caption TBL-MAIN-EPSILON). + +### Listing in Main + +```{#lst-main-epsilon .python lst-cap="LST-MAIN-EPSILON"} +b = 5 # main listing 5 +``` + +Reference to @lst-main-epsilon (Listing 5, caption LST-MAIN-EPSILON). + +### Equation in Main + +$$ +\epsilon = 5 +$$ {#eq-main-epsilon} + +Reference to @eq-main-epsilon (Equation 5). + +## Round 6: More Margin Caption + +### Figure with Margin Caption + +![FIG-MARGINCAP-ZETA](https://placehold.co/400x200/teal/white?text=Fig6){#fig-margincap-zeta .margin-caption} + +Reference to @fig-margincap-zeta (Figure 6, caption FIG-MARGINCAP-ZETA in margin). + +### Table with Margin Caption + +| G | H | I | +|---|---|---| +| x | y | z | + +: TBL-MARGINCAP-ZETA {#tbl-margincap-zeta .margin-caption} + +Reference to @tbl-margincap-zeta (Table 6, caption TBL-MARGINCAP-ZETA in margin). + +### Listing with Margin Caption + +```{#lst-margincap-zeta .python lst-cap="LST-MARGINCAP-ZETA" .margin-caption} +c = 6 # main listing with margin caption 6 +``` + +Reference to @lst-margincap-zeta (Listing 6, caption LST-MARGINCAP-ZETA in margin). + +### Equation in Main + +$$ +\zeta = 6 +$$ {#eq-main-zeta} + +Reference to @eq-main-zeta (Equation 6). + +## Summary of Cross-References + +All references should show correct sequential numbering: + +**Figures:** @fig-margin-alpha, @fig-main-beta, @fig-margincap-gamma, @fig-margin-delta, @fig-main-epsilon, @fig-margincap-zeta + +**Tables:** @tbl-margin-alpha, @tbl-main-beta, @tbl-margincap-gamma, @tbl-margin-delta, @tbl-main-epsilon, @tbl-margincap-zeta + +**Listings:** @lst-margin-alpha, @lst-main-beta, @lst-margincap-gamma, @lst-margin-delta, @lst-main-epsilon, @lst-margincap-zeta + +**Equations:** @eq-margin-alpha, @eq-main-beta, @eq-main-gamma, @eq-margin-delta, @eq-main-epsilon, @eq-main-zeta diff --git a/tests/docs/smoke-all/typst/margin-layout/custom-geometry-asymmetric.qmd b/tests/docs/smoke-all/typst/margin-layout/custom-geometry-asymmetric.qmd new file mode 100644 index 00000000000..d774571ba56 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/custom-geometry-asymmetric.qmd @@ -0,0 +1,76 @@ +--- +title: "Custom Geometry: Asymmetric Page Margins" +papersize: us-letter +margin: + left: 0.75in + right: 0.5in +grid: + margin-width: 2.5in + gutter-width: 0.4in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # inner block contains sep: 0.750in (the left margin) + # outer block contains far: 0.500in, width: 2.500in, sep: 0.400in + - ['inner:', 'sep: 0.750in', 'outer:', 'far: 0.500in', 'width: 2.500in', 'sep: 0.400in'] + - [] + ensurePdfRegexMatches: + - - 'Figure 1' + - 'asymmetric' + - 'tight\s+right\s+padding' + - [] + ensurePdfTextPositions: + - # Margin notes are in margin (rightOf body text) with asymmetric geometry + - subject: "asymmetric layout" + relation: rightOf + object: "Asymmetric Page Layout" + - subject: "asymmetric layout" + relation: above + object: "pushes the text" + - [] + noErrors: default +--- + +```{r} +#| label: setup +#| echo: false +library(ggplot2) +``` + +## Asymmetric Page Layout Test + +This document tests asymmetric page margins: 0.75in left margin and 0.5in right padding. +[Combined with a 2.5in margin column, this creates an asymmetric layout with tight right padding.]{.aside} + +The left page margin is slightly smaller than default (0.75in vs 1in), and the right edge padding is tighter (0.5in). +[This layout pushes the text toward the left and gives more room for margin notes.]{.aside} + +```{r} +#| label: fig-asymmetric +#| echo: false +#| fig-cap: "Phase-shifted waves (seed 2024)" + +set.seed(2024) +x <- seq(0, 4 * pi, length.out = 200) +df <- data.frame( + x = rep(x, 4), + y = c(sin(x), + sin(x + pi/4) * 0.9 + rnorm(200, 0, 0.02), + sin(x + pi/2) * 0.8 + rnorm(200, 0, 0.02), + sin(x + 3*pi/4) * 0.7 + rnorm(200, 0, 0.02)), + wave = rep(c("0 deg", "45 deg", "90 deg", "135 deg"), each = 200) +) +ggplot(df, aes(x, y, color = wave)) + + geom_line(linewidth = 1) + + theme_minimal() + + labs(x = NULL, y = NULL, title = "Phase Shifts") + + theme(legend.position = "bottom") +``` + +[Asymmetric layouts can be useful for binding or visual balance.]{.aside} + +The grid options (margin-width, gutter-width) control the internal column layout, while margin options (left, right) control the page boundaries. diff --git a/tests/docs/smoke-all/typst/margin-layout/custom-geometry-narrow.qmd b/tests/docs/smoke-all/typst/margin-layout/custom-geometry-narrow.qmd new file mode 100644 index 00000000000..53112bd5019 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/custom-geometry-narrow.qmd @@ -0,0 +1,72 @@ +--- +title: "Custom Geometry: Narrow Margins" +papersize: us-letter +grid: + margin-width: 1.5in + gutter-width: 0.2in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # outer block contains width: 1.500in (margin column) and sep: 0.200in (gutter) + - ['outer:', 'width: 1.500in', 'sep: 0.200in'] + - [] + ensurePdfRegexMatches: + - - 'Figure 1' + - 'Narrow margin' + - 'compact notes' + - [] + ensurePdfTextPositions: + - # Margin notes are in margin (rightOf body text) with narrow geometry + - subject: "compact notes" + relation: rightOf + object: "Narrow Margin Layout" + - subject: "compact notes" + relation: above + object: "Short annotations" + - [] + noErrors: default +--- + +```{r} +#| label: setup +#| echo: false +library(ggplot2) +``` + +## Narrow Margin Layout Test + +This document tests a narrow margin configuration with 1.5-inch margin notes and a tight 0.2-inch gutter. +[Narrow margins require more compact notes.]{.aside} + +The text column is wider, giving more room for the main content. +[Short annotations work best here.]{.aside} + +```{r} +#| label: fig-narrow-margin +#| echo: false +#| fig-cap: "Damped oscillation (seed 777)" + +set.seed(777) +x <- seq(0, 4 * pi, length.out = 200) +damping <- exp(-x / 6) +df <- data.frame( + x = rep(x, 3), + y = c(sin(x) * damping + rnorm(200, 0, 0.02), + sin(2 * x) * damping * 0.7 + rnorm(200, 0, 0.02), + sin(3 * x) * damping * 0.5 + rnorm(200, 0, 0.02)), + wave = rep(c("Fundamental", "2nd Harmonic", "3rd Harmonic"), each = 200) +) +ggplot(df, aes(x, y, color = wave)) + + geom_line(linewidth = 1) + + theme_minimal() + + labs(x = NULL, y = NULL) + + theme(legend.position = "bottom") +``` + +[The narrow margin still works for brief notes.]{.aside} + +This layout maximizes reading space while still allowing margin annotations. diff --git a/tests/docs/smoke-all/typst/margin-layout/custom-geometry-wide.qmd b/tests/docs/smoke-all/typst/margin-layout/custom-geometry-wide.qmd new file mode 100644 index 00000000000..afa224948ed --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/custom-geometry-wide.qmd @@ -0,0 +1,94 @@ +--- +title: "Custom Geometry: Wide Margins" +papersize: us-letter +grid: + margin-width: 3in + gutter-width: 0.5in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # outer block contains width: 3.000in (margin column) and sep: 0.500in (gutter) + - ['outer:', 'width: 3.000in', 'sep: 0.500in'] + - [] + ensurePdfRegexMatches: + - - 'Figure 1' + - 'Figure 2' + - 'Wide margin' + - 'This is a note' + - [] + ensurePdfTextPositions: + - # Margin notes are in margin (rightOf body text) with wide geometry + - subject: "This is a note" + relation: rightOf + object: "Wide Margin Layout" + - subject: "This is a note" + relation: above + object: "Additional context" + - [] + noErrors: default +--- + +```{r} +#| label: setup +#| echo: false +library(ggplot2) +``` + +## Wide Margin Layout Test + +This document tests a wide margin configuration with 3-inch margin notes and 0.5-inch gutter. +[This is a note in the wide 3-inch margin column. It has plenty of room for detailed annotations.]{.aside} + +The text column is narrower to accommodate the larger margin area. +[Additional context can be placed here without feeling cramped.]{.aside} + +```{r} +#| label: fig-wide-margin-1 +#| echo: false +#| fig-cap: "Sine waves with wide margin (seed 42)" + +set.seed(42) +x <- seq(0, 4 * pi, length.out = 200) +df <- data.frame( + x = rep(x, 3), + y = c(sin(x), sin(2 * x + runif(1)) * 0.7 + rnorm(200, 0, 0.05), + sin(0.5 * x + runif(1)) * 0.8 + rnorm(200, 0, 0.05)), + wave = rep(c("Wave 1", "Wave 2", "Wave 3"), each = 200) +) +ggplot(df, aes(x, y, color = wave)) + + geom_line(linewidth = 1) + + theme_minimal() + + labs(x = NULL, y = NULL) + + theme(legend.position = "bottom") +``` + +[Wide margin layouts are ideal for documents with extensive annotations, like academic papers or technical documentation.]{.aside} + +Another figure with different data to verify cross-references work correctly. + +```{r} +#| label: fig-wide-margin-2 +#| echo: false +#| fig-cap: "Cosine waves with noise (seed 123)" + +set.seed(123) +x <- seq(0, 4 * pi, length.out = 200) +df <- data.frame( + x = rep(x, 3), + y = c(cos(x) + rnorm(200, 0, 0.03), + cos(2 * x) * 0.6 + rnorm(200, 0, 0.04), + cos(0.3 * x) * 1.2 + rnorm(200, 0, 0.02)), + wave = rep(c("Primary", "Harmonic", "Subharmonic"), each = 200) +) +ggplot(df, aes(x, y, color = wave)) + + geom_line(linewidth = 1) + + theme_minimal() + + labs(x = NULL, y = NULL) + + theme(legend.position = "bottom") +``` + +References: See @fig-wide-margin-1 and @fig-wide-margin-2 for the wave plots. diff --git a/tests/docs/smoke-all/typst/margin-layout/fullwidth-div.qmd b/tests/docs/smoke-all/typst/margin-layout/fullwidth-div.qmd new file mode 100644 index 00000000000..5bde0734df9 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/fullwidth-div.qmd @@ -0,0 +1,42 @@ +--- +title: "Full Width Div Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#wideblock\(side: "outer"\)', '#wideblock\(side: "both"\)', '#wideblock\(side: "inner"\)'] + - [] + ensurePdfTextPositions: + - # Margin note is in margin (rightOf body text) + - subject: "MARGIN-NOTE-DIV" + relation: rightOf + object: "Full Width Div" + - # Negative: fullwidth content NOT rightOf margin note + - subject: "FULLWIDTH-RIGHT-CONTENT" + relation: rightOf + object: "MARGIN-NOTE-DIV" + noErrors: default +--- + +## Column Page Right +[MARGIN-NOTE-DIV: This note stays in the margin while the div extends across.]{.aside} + +::: {.column-page-right} +FULLWIDTH-RIGHT-CONTENT: {{< lipsum 1 >}} +::: + +## Column Page (Full Width) + +::: {.column-page} +{{< lipsum 2 >}} +::: + +## Column Page Left + +::: {.column-page-left} +{{< lipsum 1 >}} +::: diff --git a/tests/docs/smoke-all/typst/margin-layout/fullwidth-figure.qmd b/tests/docs/smoke-all/typst/margin-layout/fullwidth-figure.qmd new file mode 100644 index 00000000000..74ca20451ab --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/fullwidth-figure.qmd @@ -0,0 +1,96 @@ +--- +title: "Full Width Figure Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#wideblock\(side: "outer"\)', '#wideblock\(side: "both"\)'] + - [] + ensurePdfRegexMatches: + # Verify figure captions appear at the figures + - - 'Figure 1: CAP-WIDE-RIGHT' + - 'Figure 2: CAP-FULL-PAGE' + # Verify cross-references resolve correctly (allow whitespace/newlines) + - 'XREF1\s+Figure 1' + - 'XREF2\s+Figure 2' + - [] + ensurePdfTextPositions: + - # Aside text is in margin (rightOf body text) + - subject: "column-page-right" + relation: rightOf + object: "Page Right" + # Aside is rightOf fullwidth caption (aside in far margin, caption spans body) + - subject: "column-page-right" + relation: rightOf + object: "CAP-WIDE-RIGHT" + - # Negative: fullwidth caption NOT rightOf aside (caption not in far margin) + - subject: "CAP-WIDE-RIGHT" + relation: rightOf + object: "column-page-right" + noErrors: default +--- + +```{r} +#| label: setup +#| echo: false +library(ggplot2) +``` + +## Full Width Figure (Page Right) +[The `.column-page-right` class extends the figure into the right margin while keeping the left edge aligned with the text column.]{.aside} + +This figure extends into the right margin. Compare with XREF2 @fig-full-page below. +[Wave 1 uses the base frequency, Wave 2 doubles it, and Wave 3 halves it with a phase offset.]{.aside} + +```{r} +#| label: fig-wide-right +#| echo: false +#| fig-cap: "CAP-WIDE-RIGHT" +#| column: page-right +#| out-width: 100% + +x <- seq(0, 4 * pi, length.out = 200) +df <- data.frame( + x = rep(x, 3), + y = c(sin(x), sin(2 * x) * 0.7 + 0.5, sin(0.5 * x + 1) * 0.8 - 0.3), + wave = rep(c("Wave 1", "Wave 2", "Wave 3"), each = 200) +) +ggplot(df, aes(x, y, color = wave)) + + geom_line(linewidth = 1) + + theme_minimal() + + labs(x = NULL, y = NULL) + + theme(legend.position = "bottom") +``` + +## Full Width Figure (Full Page) +[The `.column-page` class spans the entire page width, extending into both margins.]{.aside} + +This figure spans the full page. Unlike XREF1 @fig-wide-right above, it extends into both margins. +[Full-width figures are ideal for time series, wide tables, or any content where horizontal space improves readability.]{.aside} + +```{r} +#| label: fig-full-page +#| echo: false +#| fig-cap: "CAP-FULL-PAGE" +#| column: page +#| out-width: 100% + +ggplot(df, aes(x, y, color = wave)) + + geom_line(linewidth = 1.2) + + theme_minimal() + + labs(x = NULL, y = NULL) + + theme(legend.position = "bottom") +``` + +## Summary +[Margin layouts in Typst use the marginalia package under the hood.]{.aside} + +These examples demonstrate how Quarto's column classes translate to Typst's margin layout system. The margin notes you see alongside this text provide additional context without interrupting the main narrative flow. +[This approach follows Edward Tufte's principles of information design, keeping supplementary details accessible but unobtrusive.]{.aside} + +When combining full-width figures with margin notes, the layout engine automatically handles the spacing and positioning to prevent overlaps. +[The `papersize` option must be set for margin layouts to calculate geometry correctly.]{.aside} diff --git a/tests/docs/smoke-all/typst/margin-layout/fullwidth-listing.qmd b/tests/docs/smoke-all/typst/margin-layout/fullwidth-listing.qmd new file mode 100644 index 00000000000..6273950f5e6 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/fullwidth-listing.qmd @@ -0,0 +1,66 @@ +--- +title: "Full Width Listing Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Full-width listings should use wideblock + - ['#wideblock\(side: "outer"\)', '#wideblock\(side: "both"\)'] + - [] + ensurePdfRegexMatches: + - ['Listing 1', 'FULLWIDTH-CSS-STYLES', 'Listing 2', 'FULLWIDTH-SHELL-PIPELINE'] + - [] + ensurePdfTextPositions: + - # Margin note is in margin (rightOf body text) + - subject: "MARGIN-NOTE-LISTING" + relation: rightOf + object: "Full Width Listing" + - # Negative: fullwidth listing caption NOT rightOf margin note + - subject: "FULLWIDTH-CSS-STYLES" + relation: rightOf + object: "MARGIN-NOTE-LISTING" + noErrors: default +--- + +This tests code listings that extend into the margins for displaying wide code. +[MARGIN-NOTE-LISTING: This note stays in the margin while listings extend across.]{.aside} + +## CSS with Long Selectors (Page Right) + +```{#lst-fullwidth-css .css .column-page-right lst-cap="FULLWIDTH-CSS-STYLES"} +/* Complex CSS selectors and media queries that benefit from wide display */ +.dashboard-container .sidebar-navigation .menu-item.active .submenu-list .submenu-item:hover .submenu-link { color: #3498db; background-color: rgba(52, 152, 219, 0.1); transition: all 0.3s ease-in-out; } +.dashboard-container .main-content .card-grid .card-item .card-header .card-title { font-size: 1.25rem; font-weight: 600; color: #2c3e50; margin-bottom: 0.5rem; } +@media screen and (min-width: 1200px) and (max-width: 1600px) { .dashboard-container .sidebar-navigation { width: 280px; } .dashboard-container .main-content { margin-left: 300px; padding: 2rem; } } +@media screen and (min-width: 1600px) { .dashboard-container .sidebar-navigation { width: 320px; } .dashboard-container .main-content { margin-left: 340px; padding: 2.5rem; } } +``` + +See @lst-fullwidth-css for CSS styles using wide layout. + +## Shell Pipeline (Full Page) + +```{#lst-fullwidth-shell .bash .column-page lst-cap="FULLWIDTH-SHELL-PIPELINE"} +# Complex data processing pipeline that benefits from full-width display to avoid line wrapping +cat /var/log/application/access.log | grep -E "^[0-9]{4}-[0-9]{2}-[0-9]{2}" | awk -F'\t' '{print $1, $4, $7, $9}' | sort -t' ' -k1,1 -k2,2 | uniq -c | sort -rn | head -100 | tee /tmp/top_requests.txt + +# Docker command with many configuration options +docker run --detach --name production-api-server --hostname api.example.com --network bridge --publish 8080:8080 --publish 8443:8443 --volume /etc/ssl/certs:/etc/ssl/certs:ro --volume /var/log/api:/var/log/api:rw --env NODE_ENV=production --env DATABASE_URL=postgresql://user:pass@db.example.com:5432/production --env REDIS_URL=redis://cache.example.com:6379 --env JWT_SECRET_KEY=supersecretkey123 --memory 2g --cpus 2 --restart unless-stopped myregistry.example.com/api-server:v2.3.1 + +# Kubernetes kubectl command with complex selectors - uses spaces between arguments to allow wrapping +kubectl get pods --all-namespaces --selector 'app.kubernetes.io/component=backend,environment in (staging, production)' -o wide --sort-by '.metadata.creationTimestamp' --show-labels --field-selector 'status.phase!=Succeeded' --chunk-size 500 +``` + +See @lst-fullwidth-shell for shell pipelines using full-page width. + +## Summary + +Full-width code listings are useful for: + +- CSS with deeply nested selectors +- Shell commands with many arguments +- Long SQL queries +- Configuration files with long values diff --git a/tests/docs/smoke-all/typst/margin-layout/fullwidth-screen.qmd b/tests/docs/smoke-all/typst/margin-layout/fullwidth-screen.qmd new file mode 100644 index 00000000000..4a8ed2b84ac --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/fullwidth-screen.qmd @@ -0,0 +1,37 @@ +--- +title: "Column Screen Mapping Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # column-screen* classes should map to wideblock with appropriate sides + - ['#wideblock\(side: "both"\)'] + - [] + ensurePdfTextPositions: + - # Aside is above wideblock content (wideblock spans full page) + - subject: "haunts Stack Overflow" + relation: above + object: + text: "catastrophic backtracking" + type: Decoration + - [] + noErrors: default +--- + +## Column Screen (Maps to Full Page) +[Zalgo text exploits Unicode combining diacritical marks to stack characters vertically, creating the "corrupted" appearance that haunts Stack Overflow regex questions.]{.aside} + +::: {.column-screen} +Look, I know you want to parse HTML with regex, but you really, truly, cannot do this correctly in the general case because HTML is not a regular language and the regex engine will consume your sanity before it consumes the closing tags, and honestly at this point you should just use a proper HTML parser like BeautifulSoup or lxml or even the browser's built-in DOMParser because life is too short to debug catastrophic backtracking at 3am when your boss asks why the production server is at 100% CPU because someone put a nested div inside another nested div inside yet another nested div and your regex is now contemplating the heat death of the universe and t̃hẽ c̃õm̃b̃ĩñĩñg̃ m̃ãr̃k̃s̃ ãr̃ẽ s̃t̃ãr̃t̃ĩñg̃ t̃õ ãp̃p̃ẽãr̃ and ṱ̈ḧ̤ë̤ṳ̈ ä̤r̤̈ë̤ g̤̈ë̤ṱ̈ṱ̈ï̤n̤̈g̤̈ d̤̈ë̤ë̤p̤̈ë̤r̤̈ and T̸̢̧̛̮̣͓̦̱̲̪̯͎͇̙̼̬̪͇̿̈́̆̏̎̾̇̈́͒͗̑̒̎̎͂̕͜͝͝H̵̢̨̛̙̣̜̫̪̼̫͖̙̤̫̫̪͍̒̈́̅̆̌̈́̊̉́̽̐͆̏̎̕͝͠E̸̢̧̛̬̫̼̙͓̦̫̣̯͎̪̭͎̫͓̿̈́̏̆̎̾̊̈́͒̑͗̒̎̕͜͝͝ ̵̧̨̛̮̪̣͓̦̱̲̫̯͎̭̙̼͇̿̈́̆̎̾̇̈́͒͗̑̒̎̎̕͜͝͝V̸̢̧̛̬̫̼̙͓̦̫̣̯͎̪̭͎̫͓̒̈́̏̆̅̎̾̊̈́͒̑͗̒̎̕͜͝͝Ơ̵̧̨̮̪̣͓̦̱̲̫̯͎̭̙̼͇̈́̿̈́̆̎̾̇̈́͒͗̑̒̎̎̕͜͝͝I̸̢̧̛̬̫̼̙͓̦̫̣̯͎̪̭͎̫͓̿̈́̏̆̅̎̾̊̈́͒̑͗̒̎̕͜͝͝Ḑ̵̨̛̮̪̣͓̦̱̲̫̯͎̭̙̼͇̿̈́̆̎̾̇̈́͒͗̑̒̎̎̕͜͝͝ ̸̢̧̛̬̫̼̙͓̦̫̣̯͎̪̭͎̫͓̿̈́̏̆̅̎̾̊̈́͒̑͗̒̎̕͜͝͝Ç̵̨̛̮̪̣͓̦̱̲̫̯͎̭̙̼͇̿̈́̆̎̾̇̈́͒͗̑̒̎̎̕͜͝͝Ơ̸̢̧̬̫̼̙͓̦̫̣̯͎̪̭͎̫͓̂̿̈́̏̆̅̎̾̊̈́͒̑͗̒̎̕͜͝͝Ņ̵̨̛̮̪̣͓̦̱̲̫̯͎̭̙̼͇̿̈́̆̎̾̇̈́͒͗̑̒̎̎̕͜͝͝S̸̢̧̛̬̫̼̙͓̦̫̣̯͎̪̭͎̫͓̿̈́̏̆̅̎̾̊̈́͒̑͗̒̎̕͜͝͝Ų̵̧̨̛̮̪̣͓̦̱̲̫̯͎̭̙̼͇̿̈́̆̎̾̇̈́͒͗̑̒̎̎̕͜͝͝M̸̢̧̛̬̫̼̙͓̦̫̣̯͎̪̭͎̫͓̿̈́̏̆̅̎̾̊̈́͒̑͗̒̎̕͜͝͝Ȩ̵̧̨̛̮̪̣͓̦̱̲̫̯͎̭̙̼͇̿̈́̆̎̾̇̈́͒͗̑̒̎̎̕͜͝͝S̸̢̧̛̬̫̼̙͓̦̫̣̯͎̪̭͎̫͓̿̈́̏̆̅̎̾̊̈́͒̑͗̒̎̕͜͝͝ +::: + + +## Column Screen Inset + +::: {.column-screen-inset} +The thing about Zalgo text is that it technically works by stacking Unicode combining characters above and below the base character, and while most text rendering systems will dutifully attempt to display U+0300 through U+036F stacked seventeen layers high, this does not mean your database schema was prepared for a username field containing what appears to be an eldritch summoning ritual, and no amount of VARCHAR(255) is going to save you when someone pastes in the collected works of H.P. Lovecraft rendered entirely in combining marks, so please just sanitize your inputs and also maybe consider that the void gazes also into you. H̶̷̸̴̵̨̧̡̢̛̪̫̬̭̮̯̰̱̲̳̹̺̻̼̽̾̿̀́͂̓̈́͆͊͋͌͐͑͒͗͘̕͜͝͞͠͡ͅE̶̷̸̴̵̡̢̨̧̛̖̗̘̙̽̾̿̀́͂̓̈́͆͊͋͌͐͑͒͗̚͘̕͜͝͞͠͡ͅ ̶̷̸̴̵̧̨̡̢̛̜̝̞̟̠̣̤̥̦̩̪̫̬̭̮̯̰̱̲̳̹̺̻̼̽̾̿̀́͂̓̈́͆͊͋͌͐͑͒͗͘̕͜͝͞͠͡ͅC̶̷̸̴̵̨̧̡̢̛̬̭̮̯̰̱̲̳̹̺̻̼̽̾̿̀́͂̓̈́͆͊͋͌͐͑͒͗͘̕͜͝͞͠͡ͅO̶̷̸̴̵̡̢̨̧̽̾̿̀́͂̓̈́͆͊͋͌͐͑͒͗͘̕͜͝͞͠͡M̶̷̸̴̵̧̨̡̢̛̽̾̿̀́͂̓̈́͆͊͋͌͐͑͒͗͘̕͜͝͞͠͡E̶̷̸̴̵̡̢̨̧̛̽̾̿̀́͂̓̈́͆͊͋͌͐͑͒͗͘̕͜͝͞͠͡S̶̷̸̴̵̨̧̡̢̛̽̾̿̀́͂̓̈́͆͊͋͌͐͑͒͗͘̕͜͝͞͠͡ +::: diff --git a/tests/docs/smoke-all/typst/margin-layout/fullwidth-table-great-tables.qmd b/tests/docs/smoke-all/typst/margin-layout/fullwidth-table-great-tables.qmd new file mode 100644 index 00000000000..9350e1950d0 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/fullwidth-table-great-tables.qmd @@ -0,0 +1,135 @@ +--- +title: "Full Width Table Test" +papersize: us-letter +format: + typst: + keep-typ: true + include-in-header: + text: | + #show figure: set block(breakable: true) +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#wideblock\(side: "outer"\)', '#wideblock\(side: "both"\)'] + - [] + ensurePdfRegexMatches: + - - 'Table 1' + - 'Table 2' + - 'Oceania' + - [] + ensurePdfTextPositions: + - # Aside in margin (rightOf body text) + - subject: "Palau to Tuvalu" + relation: rightOf + object: "extends into the right margin" + - # Negative: table content NOT rightOf aside (Tonga only in first table) + - subject: "Tonga" + relation: rightOf + object: "Palau to Tuvalu" + noErrors: default +--- + +## Full Width Table (Page Right) +[From Palau to Tuvalu, these island nations span thousands of miles of Pacific Ocean.]{.aside} + +This table extends into the right margin using great_tables with Oceania population data. + +```{python} +#| classes: plain +#| echo: false +#| label: tbl-wide-right +#| tbl-cap: "Oceania Population Growth (Page Right)" +#| column: page-right + +from great_tables import GT +from great_tables.data import countrypops +import polars as pl +import polars.selectors as cs + +oceania = { + "Australasia": ["AU", "NZ"], + "Melanesia": ["NC", "PG", "SB", "VU"], + "Micronesia": ["FM", "GU", "KI", "MH", "MP", "NR", "PW"], + "Polynesia": ["PF", "WS", "TO", "TV"], +} + +country_to_region = { + country: region for region, countries in oceania.items() for country in countries +} + +wide_pops = ( + pl.from_pandas(countrypops) + .filter( + pl.col("country_code_2").is_in(list(country_to_region)) + & pl.col("year").is_in([1960, 1980, 2000, 2020]) + ) + .with_columns(pl.col("country_code_2").replace(country_to_region).alias("region")) + .pivot(index=["country_name", "region"], on="year", values="population") + .sort("2020", descending=True) +) + +( + GT(wide_pops, rowname_col="country_name", groupname_col="region") + .tab_header( + title="Populations of Oceania's Countries", + subtitle="Extended into right margin" + ) + .tab_spanner(label="Population by Decade", columns=cs.all()) + .fmt_integer() + .tab_stubhead(label="Country") +) +``` + +## Full Width Table (Full Page) + +This table spans the full page with detailed population data and growth calculations. + +```{python} +#| classes: plain +#| echo: false +#| label: tbl-full-page +#| tbl-cap: "Oceania Population with Growth Rates (Full Page)" +#| column: page + +from great_tables import GT +from great_tables.data import countrypops +import polars as pl +import polars.selectors as cs + +oceania = { + "Australasia": ["AU", "NZ"], + "Melanesia": ["NC", "PG", "SB", "VU"], +} + +country_to_region = { + country: region for region, countries in oceania.items() for country in countries +} + +wide_pops = ( + pl.from_pandas(countrypops) + .filter( + pl.col("country_code_2").is_in(list(country_to_region)) + & pl.col("year").is_in([1960, 1970, 1980, 1990, 2000, 2010, 2020]) + ) + .with_columns(pl.col("country_code_2").replace(country_to_region).alias("region")) + .pivot(index=["country_name", "region"], on="year", values="population") + .sort("2020", descending=True) +) + +( + GT(wide_pops, rowname_col="country_name", groupname_col="region") + .tab_header( + title="Populations of Oceania's Countries", + subtitle="Australasia and Melanesia only (seven decades)" + ) + .tab_spanner(label="Population by Decade", columns=cs.all()) + .fmt_integer() + .tab_stubhead(label="Country") + .tab_source_note(source_note="Source: World Bank Open Data") +) +``` + +## Summary + +These examples demonstrate how Quarto's column classes translate to Typst's wide layout system for great_tables tables. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-caption-docwide.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-caption-docwide.qmd new file mode 100644 index 00000000000..991c86005bb --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-caption-docwide.qmd @@ -0,0 +1,40 @@ +--- +title: "Document-wide Margin Captions" +papersize: us-letter +fig-cap-location: margin +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Uses show rule approach for margin captions + - ['#show figure.caption:', 'note\('] + - [] + ensurePdfRegexMatches: + - - 'Figure 1' + - 'Figure 2' + - 'CAP-FIRST' + - 'CAP-SECOND' + - [] + ensurePdfTextPositions: + - - subject: "CAP-FIRST" + relation: rightOf + object: "This document" + - subject: "CAP-FIRST" + relation: above + object: "CAP-SECOND" + - [] + noErrors: default +--- + +This document tests document-wide margin captions with `fig-cap-location: margin`. + +![CAP-FIRST](test-image.png){#fig-first} + +Some text between figures. + +![CAP-SECOND](test-image.png){#fig-second} + +References: @fig-first and @fig-second. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-caption-interleaved.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-caption-interleaved.qmd new file mode 100644 index 00000000000..2342b507951 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-caption-interleaved.qmd @@ -0,0 +1,75 @@ +--- +title: "Interleaved Margin Caption Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensurePdfRegexMatches: + - - 'REF-ALPHA pointing to Figure 1' + - 'REF-BETA pointing to Figure 2' + - 'REF-GAMMA pointing to Figure 3' + - 'REF-DELTA pointing to Figure 4' + - 'REF-EPSILON pointing to Figure 5' + - 'Figure 1' + - 'Figure 2' + - 'Figure 3' + - 'Figure 4' + - 'Figure 5' + - [] + ensurePdfTextPositions: + - # Margin captions right of body (same page) + - subject: "CAP-ALPHA" + relation: rightOf + object: "First: Margin" + # Cross-refs left of margin captions (same page) + - subject: "REF-ALPHA" + relation: leftOf + object: "CAP-ALPHA" + - [] + noErrors: default +--- + +This document tests interleaved margin and regular captions with sequential numbering. + +## First: Margin Caption + +![CAP-ALPHA with margin caption](test-plot.svg){#fig-alpha cap-location="margin"} + +Text with REF-ALPHA pointing to @fig-alpha. + +## Second: Regular Caption + +![CAP-BETA with regular caption](test-plot.svg){#fig-beta} + +Text with REF-BETA pointing to @fig-beta. + +## Third: Back to Margin Caption + +![CAP-GAMMA with margin caption](test-plot.svg){#fig-gamma cap-location="margin"} + +Text with REF-GAMMA pointing to @fig-gamma. + +## Fourth: Regular Caption Again + +![CAP-DELTA with regular caption](test-plot.svg){#fig-delta} + +Text with REF-DELTA pointing to @fig-delta. + +## Fifth: Margin Caption Once More + +![CAP-EPSILON with margin caption](test-plot.svg){#fig-epsilon cap-location="margin"} + +Text with REF-EPSILON pointing to @fig-epsilon. + +## Summary + +All figures should be numbered sequentially 1-5: + +- @fig-alpha should be Figure 1 (margin caption) +- @fig-beta should be Figure 2 (regular caption) +- @fig-gamma should be Figure 3 (margin caption) +- @fig-delta should be Figure 4 (regular caption) +- @fig-epsilon should be Figure 5 (margin caption) diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-caption-listing.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-caption-listing.qmd new file mode 100644 index 00000000000..425cab64aa4 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-caption-listing.qmd @@ -0,0 +1,58 @@ +--- +title: "Margin Caption Listing Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Listing with margin caption uses show rule approach + - ['#show figure.caption:', 'note\(', 'kind: "quarto-float-lst"'] + - [] + ensurePdfRegexMatches: + - ['Listing 1', 'MARGINCAP-SQL-QUERY', 'Listing 2', 'MARGINCAP-PYTHON-FUNC'] + - [] + ensurePdfTextPositions: + - - subject: "MARGINCAP-SQL-QUERY" + relation: rightOf + object: "SELECT" + - subject: "MARGINCAP-PYTHON-FUNC" + relation: rightOf + object: "calculate_statistics" + - subject: "MARGINCAP-SQL-QUERY" + relation: above + object: "MARGINCAP-PYTHON-FUNC" + - [] + noErrors: default +--- + +This tests code listings with captions placed in the margin. + +## SQL Query with Margin Caption + +```{#lst-margincap-sql .sql lst-cap="MARGINCAP-SQL-QUERY" .margin-caption} +SELECT customers.id, customers.name, orders.total +FROM customers +INNER JOIN orders ON customers.id = orders.customer_id +WHERE orders.status = 'completed' +ORDER BY orders.total DESC +LIMIT 10; +``` + +See @lst-margincap-sql for the SQL query with its caption in the margin. + +## Python Function with Margin Caption + +```{#lst-margincap-python .python lst-cap="MARGINCAP-PYTHON-FUNC" .margin-caption} +def calculate_statistics(data): + """Calculate basic statistics.""" + mean = sum(data) / len(data) + variance = sum((x - mean) ** 2 for x in data) / len(data) + return {"mean": mean, "std": variance ** 0.5} +``` + +See @lst-margincap-python for the Python function with margin caption. + +The code blocks stay in the main column while their captions appear in the margin. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-caption-tables.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-caption-tables.qmd new file mode 100644 index 00000000000..dd3a7415f63 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-caption-tables.qmd @@ -0,0 +1,95 @@ +--- +title: "Margin Caption Tables and Figures Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # All margin captions use show rule with top alignment, scoped with #[...] + - ['#show figure.caption:', 'note\(alignment: "top"', '#\['] + - [] + ensurePdfRegexMatches: + # Check caption labels (anchor text) + - - 'Figure 1: CAP-FIG-ONE' + - 'Table 1: CAP-TBL-ONE' + - 'Figure 2: CAP-FIG-TWO' + - 'Table 2: CAP-TBL-TWO' + - 'Figure 3: CAP-FIG-THREE' + # Check cross-references + - 'REF-FIG-ONE pointing to Figure 1' + - 'REF-TBL-ONE pointing to Table 1' + - 'REF-FIG-TWO pointing to Figure 2' + - 'REF-TBL-TWO pointing to Table 2' + - 'REF-FIG-THREE pointing to Figure 3' + # Ensure wrong values don't appear + - - 'Table 0' + - 'Figure 0' + ensurePdfTextPositions: + - # First page: margin captions right of body, refs left of captions + - subject: "CAP-FIG-ONE" + relation: rightOf + object: "First Figure" + - subject: "REF-FIG-ONE" + relation: leftOf + object: "CAP-FIG-ONE" + - subject: "CAP-FIG-ONE" + relation: above + object: "CAP-TBL-ONE" + - [] + noErrors: default +--- + +This document tests interleaved tables and figures with margin captions, verifying separate counters. + +## First Figure + +![CAP-FIG-ONE](test-plot.svg){#fig-one cap-location="margin"} + +Text with REF-FIG-ONE pointing to @fig-one. + +## First Table + +| A | B | C | +|---|---|---| +| 1 | 2 | 3 | +| 4 | 5 | 6 | + +: CAP-TBL-ONE {#tbl-one cap-location="margin"} + +Text with REF-TBL-ONE pointing to @tbl-one. + +## Second Figure + +![CAP-FIG-TWO](test-plot.svg){#fig-two cap-location="margin"} + +Text with REF-FIG-TWO pointing to @fig-two. + +## Second Table + +| X | Y | Z | +|---|---|---| +| a | b | c | +| d | e | f | + +: CAP-TBL-TWO {#tbl-two cap-location="margin"} + +Text with REF-TBL-TWO pointing to @tbl-two. + +## Third Figure + +![CAP-FIG-THREE](test-plot.svg){#fig-three cap-location="margin"} + +Text with REF-FIG-THREE pointing to @fig-three. + +## Summary + +Figures and tables should have separate sequential numbering: + +- @fig-one should be Figure 1 +- @fig-two should be Figure 2 +- @fig-three should be Figure 3 +- @tbl-one should be Table 1 +- @tbl-two should be Table 2 diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-caption.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-caption.qmd new file mode 100644 index 00000000000..7e27b00e5cf --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-caption.qmd @@ -0,0 +1,39 @@ +--- +title: "Margin Caption Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Uses show rule approach: figure.caption transformed to note() + - ['#show figure.caption:', 'note\(', 'counter: none', 'Figure'] + - [] + ensurePdfRegexMatches: + - ['Figure 1', 'CAP-MARGINCAP'] + - [] + ensurePdfTextPositions: + - - subject: "CAP-MARGINCAP" + relation: rightOf + object: "This document" + # Figure in body, caption in margin + - subject: "★" + relation: leftOf + object: "CAP-MARGINCAP" + # Test top alignment with show rule approach + # Tolerance of 12pt accounts for SVG bbox vs text position + - subject: "CAP-MARGINCAP" + relation: topAligned + object: "★" + tolerance: 12 + - [] + noErrors: default +--- + +This document tests per-figure margin captions. + +![CAP-MARGINCAP test caption](test-plot.svg){#fig-margin-cap cap-location="margin"} + +Text with REF-MARGINCAP pointing to @fig-margin-cap. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-div.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-div.qmd new file mode 100644 index 00000000000..b0a35a92744 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-div.qmd @@ -0,0 +1,25 @@ +--- +title: "Margin Div Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#note\(alignment: "baseline"'] + - [] + ensurePdfTextPositions: + - - subject: "This content appears" + relation: rightOf + object: "Main content" + - [] + noErrors: default +--- + +::: {.column-margin} +This content appears in the margin. +::: + +Main content here. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-figure-caption-above-docwide.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-figure-caption-above-docwide.qmd new file mode 100644 index 00000000000..e833c641757 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-figure-caption-above-docwide.qmd @@ -0,0 +1,38 @@ +--- +title: "Margin Figure Caption Above (Document-wide)" +papersize: us-letter +fig-cap-location: top +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#notefigure\(', 'position: top'] + - [] + ensurePdfTextPositions: + - # Both captions right of body + - subject: "CAP-FIRST" + relation: rightOf + object: "This tests" + - subject: "CAP-SECOND" + relation: rightOf + object: "More text" + # First caption above second (vertical ordering) + - subject: "CAP-FIRST" + relation: above + object: "CAP-SECOND" + - [] + noErrors: default +--- + +This tests document-wide `fig-cap-location: top` applied to margin figures. + +![CAP-FIRST](test-plot.svg){#fig-first .column-margin width=100%} + +More text in the main column. + +![CAP-SECOND](test-plot.svg){#fig-second .column-margin width=100%} + +References: @fig-first and @fig-second. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-figure-caption-above.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-figure-caption-above.qmd new file mode 100644 index 00000000000..d46a89873aa --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-figure-caption-above.qmd @@ -0,0 +1,36 @@ +--- +title: "Margin Figure Caption Above Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#notefigure\(', 'position: top'] + - [] + ensurePdfTextPositions: + - # Caption in margin, right of body + - subject: "CAP-ABOVE" + relation: rightOf + object: "This tests" + # Plot content (★ marker) in margin, right of body + - subject: "★" + relation: rightOf + object: "This tests" + # Caption ABOVE plot (cap-location: top) + - subject: "CAP-ABOVE" + relation: above + object: "★" + - [] + noErrors: default +--- + +This tests a margin figure with caption positioned above the figure content using `cap-location="top"`. + +![CAP-ABOVE](test-plot.svg){#fig-cap-above .column-margin cap-location="top" width=100%} + +See @fig-cap-above for the figure with caption above. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-figure-cell-option.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-figure-cell-option.qmd new file mode 100644 index 00000000000..dc6a0333178 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-figure-cell-option.qmd @@ -0,0 +1,45 @@ +--- +title: "Margin Figure Cell Option Test" +papersize: us-letter +grid: + margin-width: 3.5in + gutter-width: 0.25in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option should produce same output as fenced div: #notefigure + - ['#notefigure\(', 'kind: "quarto-float-fig"'] + - [] + ensurePdfRegexMatches: + - ['Figure 1', 'Scatter Plot'] + - [] + ensurePdfTextPositions: + - # Figure in margin, right of body text + - subject: "Scatter Plot" + relation: rightOf + object: "This tests" + - [] + noErrors: default +--- + +This tests an R ggplot figure placed in the margin using the `column: margin` cell option. + +```{r} +#| column: margin +#| label: fig-scatter +#| fig-cap: "Scatter Plot in Margin" +#| echo: false + +library(ggplot2) +ggplot(mtcars, aes(x = wt, y = mpg)) + + geom_point() + + theme_minimal() +``` + +As shown in @fig-scatter, the figure should appear entirely in the margin (both plot and caption). + +More text in the main column to demonstrate the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-figure-crossref-interleaved.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-figure-crossref-interleaved.qmd new file mode 100644 index 00000000000..876230e014f --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-figure-crossref-interleaved.qmd @@ -0,0 +1,82 @@ +--- +title: "Interleaved Margin Figure Cross-ref Test" +papersize: us-letter +format: + typst: + keep-typ: true + html: default + pdf: default +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#notefigure\(', '', '', '', ''] + - [] + ensurePdfRegexMatches: + - - 'REF-ALPHA pointing to Figure 1' + - 'REF-BETA pointing to Figure 2' + - 'REF-GAMMA pointing to Figure 3' + - 'REF-DELTA pointing to Figure 4' + - 'Figure 1: CAP-ALPHA' + - 'Figure 2: CAP-BETA' + - 'Figure 3: CAP-GAMMA' + - 'Figure 4: CAP-DELTA' + - [] + ensurePdfTextPositions: + - # Margin figure captions right of body + - subject: "CAP-ALPHA" + relation: rightOf + object: "Here is text" + - subject: "CAP-GAMMA" + relation: rightOf + object: "Another margin" + # Vertical ordering of margin figures + - subject: "CAP-ALPHA" + relation: above + object: "CAP-GAMMA" + - [] + noErrors: default +--- + +This document tests cross-references between margin figures and main-column figures. + +## First Section + +Here is text before the first figure. + +![CAP-ALPHA in margin](test-plot.svg){#fig-alpha .column-margin width=100%} + +Text with REF-ALPHA pointing to @fig-alpha. + +## Second Section + +Now a regular main-column figure. + +![CAP-BETA in main](test-plot.svg){#fig-beta width=50%} + +Text with REF-BETA pointing to @fig-beta. + +## Third Section + +Another margin figure to test sequencing. + +![CAP-GAMMA in margin](test-plot.svg){#fig-gamma .column-margin width=100%} + +Text with REF-GAMMA pointing to @fig-gamma. + +## Fourth Section + +Final main-column figure. + +![CAP-DELTA in main](test-plot.svg){#fig-delta width=50%} + +Text with REF-DELTA pointing to @fig-delta. + +## Summary + +All figures numbered sequentially: + +- @fig-alpha should be Figure 1 (margin) +- @fig-beta should be Figure 2 (main) +- @fig-gamma should be Figure 3 (margin) +- @fig-delta should be Figure 4 (main) diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-figure-crossref.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-figure-crossref.qmd new file mode 100644 index 00000000000..8f6221d62b7 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-figure-crossref.qmd @@ -0,0 +1,36 @@ +--- +title: "Margin Figure Cross-ref Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#notefigure\(', ''] + - [] + ensurePdfTextPositions: + - # Caption in margin, right of body + - subject: "CROSSREF-FIG-CAP" + relation: rightOf + object: "This tests" + # Plot content (★ marker) in margin + - subject: "★" + relation: rightOf + object: "This tests" + # Caption below plot + - subject: "★" + relation: above + object: "CROSSREF-FIG-CAP" + - [] + noErrors: default +--- + +This tests cross-references to margin figures. + +![CROSSREF-FIG-CAP](test-plot.svg){#fig-test .column-margin width=100%} + +As shown in @fig-test, the figure appears in the margin with a working cross-reference. + +The reference should show "Figure 1" or similar. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-figure-shift.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-figure-shift.qmd new file mode 100644 index 00000000000..3da268cb252 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-figure-shift.qmd @@ -0,0 +1,39 @@ +--- +title: "Margin Figure Shift Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['shift: false', 'shift: "avoid"'] + - [] + ensurePdfTextPositions: + - # Both figures in margin + - subject: "FIXED-FIG-CAP" + relation: rightOf + object: "Line 1" + - subject: "AVOID-FIG-CAP" + relation: rightOf + object: "Line 2" + # First figure above second + - subject: "FIXED-FIG-CAP" + relation: above + object: "AVOID-FIG-CAP" + - [] + noErrors: default +--- + +This tests shift control on margin figures. + +Line 1 with a fixed figure: + +![FIXED-FIG-CAP](test-plot.svg){#fig-fixed .column-margin shift="false" width=100%} + +Line 2 with an avoid figure: + +![AVOID-FIG-CAP](test-plot.svg){#fig-avoid .column-margin shift="avoid" width=100%} + +The first figure stays fixed, the second only shifts if necessary. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-figure.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-figure.qmd new file mode 100644 index 00000000000..d0864d5ac33 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-figure.qmd @@ -0,0 +1,36 @@ +--- +title: "Margin Figure Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#notefigure\('] + - [] + ensurePdfTextPositions: + - # Caption in margin, right of body + - subject: "MARGIN-FIG-CAP" + relation: rightOf + object: "This tests" + # Plot content (★ marker) in margin, right of body + - subject: "★" + relation: rightOf + object: "This tests" + # Caption below plot (★ is in plot title area) + - subject: "★" + relation: above + object: "MARGIN-FIG-CAP" + - [] + noErrors: default +--- + +This tests a basic figure placed in the margin. + +![MARGIN-FIG-CAP](test-plot.svg){#fig-margin .column-margin width=100%} + +See @fig-margin for the figure in the margin. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-geometry.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-geometry.qmd new file mode 100644 index 00000000000..497bc56e7ea --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-geometry.qmd @@ -0,0 +1,27 @@ +--- +title: "Typst Margin Geometry Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['inner:', 'outer:', 'sep:.*in', 'width:.*in'] + - [] + ensurePdfTextPositions: + - # Margin content is in margin (rightOf body text) + - subject: "Margin content" + relation: rightOf + object: "Text in main column" + - [] + noErrors: default +--- + +::: {.column-margin} +Margin content with letter paper size. +::: + +Text in main column. The page geometry should be automatically calculated +based on the paper size (letter = 8.5in width). diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-listing-cell-option-caption-below.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-listing-cell-option-caption-below.qmd new file mode 100644 index 00000000000..73a712a8639 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-listing-cell-option-caption-below.qmd @@ -0,0 +1,55 @@ +--- +title: "Margin Listing with Caption Below" +papersize: us-letter +grid: + margin-width: 3.5in + gutter-width: 0.25in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option produces #notefigure with position: bottom + - ['#notefigure\(', 'kind: "quarto-float-lst"', 'position: bottom'] + - [] + ensurePdfRegexMatches: + - ['Listing 1', 'MARGIN-LISTING-BELOW'] + - [] + ensurePdfTextPositions: + - # Listing in margin, right of body text - "World" unique string in code + - subject: "MARGIN-LISTING-BELOW" + relation: rightOf + object: "This tests" + - subject: "World" + relation: rightOf + object: "This tests" + # Caption BELOW code (lst-cap-location: bottom) + - subject: "World" + relation: above + object: "MARGIN-LISTING-BELOW" + - [] + noErrors: default +--- + +This tests a code listing in the margin with caption below using `cap-location: bottom`. + +```{python} +#| column: margin +#| label: lst-margin-below +#| lst-cap: "MARGIN-LISTING-BELOW" +#| lst-cap-location: bottom +#| echo: true +#| eval: false + +def greet(name): + """Simple greeting function""" + return f"Hello, {name}!" + +print(greet("World")) +``` + +As shown in @lst-margin-below, the listing should appear in the margin with caption below the code. + +More text in the main column to demonstrate the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-listing-cell-option.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-listing-cell-option.qmd new file mode 100644 index 00000000000..2346a3155fc --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-listing-cell-option.qmd @@ -0,0 +1,54 @@ +--- +title: "Margin Listing Cell Option Test" +papersize: us-letter +grid: + margin-width: 3.5in + gutter-width: 0.25in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option should produce same output as fenced div: #notefigure + - ['#notefigure\(', 'kind: "quarto-float-lst"'] + - [] + ensurePdfRegexMatches: + - ['Listing 1', 'MARGIN-CELL-OPTION-CODE'] + - [] + ensurePdfTextPositions: + - # Listing in margin, right of body text - "World" unique string in code + - subject: "MARGIN-CELL-OPTION-CODE" + relation: rightOf + object: "This tests" + - subject: "World" + relation: rightOf + object: "This tests" + # Caption above code (default for listings) + - subject: "MARGIN-CELL-OPTION-CODE" + relation: above + object: "World" + - [] + noErrors: default +--- + +This tests a code listing placed in the margin using the `column: margin` cell option. + +```{python} +#| column: margin +#| label: lst-margin-cell +#| lst-cap: "MARGIN-CELL-OPTION-CODE" +#| echo: true +#| eval: false + +def greet(name): + """Simple greeting function""" + return f"Hello, {name}!" + +print(greet("World")) +``` + +As shown in @lst-margin-cell, the listing should appear entirely in the margin (both code and caption). + +More text in the main column to demonstrate the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-listing.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-listing.qmd new file mode 100644 index 00000000000..71ca7a9508f --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-listing.qmd @@ -0,0 +1,51 @@ +--- +title: "Margin Listing Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Listings in margin divs should use notefigure + - ['#notefigure\(', 'kind: "quarto-float-lst"'] + - [] + ensurePdfRegexMatches: + - ['Listing 1', 'MARGIN-SQL-QUERY'] + - [] + ensurePdfTextPositions: + - # Margin content should be right of body text + - subject: "MARGIN-SQL-QUERY" + relation: rightOf + object: "This tests" + - subject: "SELECT" + relation: rightOf + object: "This tests" + # Caption above code (caption position: top) + - subject: "MARGIN-SQL-QUERY" + relation: above + object: "SELECT" + # Body text vertical ordering + - subject: "This tests" + relation: above + object: "More text" + - [] + noErrors: default +--- + +This tests code listings placed in the margin. + +::: {.column-margin} + +```{#lst-margin-sql .sql lst-cap="MARGIN-SQL-QUERY"} +SELECT id, name +FROM users +WHERE active = true +``` + +::: + +See @lst-margin-sql for the SQL query in the margin. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-offset.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-offset.qmd new file mode 100644 index 00000000000..6d9b69dc087 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-offset.qmd @@ -0,0 +1,29 @@ +--- +title: "Margin Offset Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#note\(alignment: "baseline", dy: -2em'] + - [] + ensurePdfTextPositions: + - - subject: "Offset margin" + relation: rightOf + object: "Main content" + # With negative dy, margin content should be above body text + - subject: "Offset margin" + relation: above + object: "Main content" + - [] + noErrors: default +--- + +::: {.column-margin dy="-2em"} +Offset margin content. +::: + +Main content here. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-span.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-span.qmd new file mode 100644 index 00000000000..fc7cff75713 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-span.qmd @@ -0,0 +1,21 @@ +--- +title: "Margin Span Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#note\(alignment: "baseline"'] + - [] + ensurePdfTextPositions: + - - subject: "margin note" + relation: rightOf + object: "Main text" + - [] + noErrors: default +--- + +Main text with [margin note]{.column-margin} inline. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-direct.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-direct.qmd new file mode 100644 index 00000000000..a5b3f9c13d4 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-direct.qmd @@ -0,0 +1,39 @@ +--- +title: "Margin Table Direct Class Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Should use notefigure for margin tables + - ['#notefigure\('] + - [] + ensurePdfTextPositions: + - # Table in margin, right of body text + - subject: "DIRECT-TBL-CAP" + relation: rightOf + object: "This tests" + - subject: "Kappa" + relation: rightOf + object: "This tests" + # Caption above table content (default for tables) + - subject: "DIRECT-TBL-CAP" + relation: above + object: "Kappa" + - [] + noErrors: default +--- + +This tests a table with margin class directly on it. + +| Col1 | Col2 | Col3 | +|-------|--------|-------| +| Kappa | Lambda | Sigma | +| Theta | Phi | Psi | + +: DIRECT-TBL-CAP {#tbl-margin .column-margin} + +See @tbl-margin for the table in the margin. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-flextable-crossref.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-flextable-crossref.qmd new file mode 100644 index 00000000000..93c3baf4d00 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-flextable-crossref.qmd @@ -0,0 +1,54 @@ +--- +title: "Margin Table flextable Cross-ref Test" +papersize: us-letter +grid: + margin-width: 3.5in + gutter-width: 0.25in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option should produce #notefigure (entire figure in margin) + - ['#notefigure\(', ''] + - [] + ensurePdfRegexMatches: + - ['Table 1', 'Iris'] + - [] + ensurePdfTextPositions: + - # Table caption in margin, right of body text + - subject: "Iris Dataset" + relation: rightOf + object: "This tests" + - [] + noErrors: default +--- + +This tests cross-references to margin tables using flextable. + +```{r} +#| column: margin +#| classes: plain +#| echo: false +#| label: tbl-iris-flex +#| tbl-cap: "Iris Dataset" + +library(flextable) + +iris_mini <- head(iris[, 1:4], 6) + +flextable(iris_mini) |> + set_header_labels( + Sepal.Length = "Sep.L", + Sepal.Width = "Sep.W", + Petal.Length = "Pet.L", + Petal.Width = "Pet.W" + ) |> + autofit() +``` + +As shown in @tbl-iris-flex, the table appears in the margin with a working cross-reference. + +The reference should show "Table 1" or similar. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-flextable.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-flextable.qmd new file mode 100644 index 00000000000..21219f97178 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-flextable.qmd @@ -0,0 +1,51 @@ +--- +title: "Margin Table with flextable" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option produces #notefigure (entire figure in margin) + # Note: flextable renders as PNG image, not native Typst table + - ['#notefigure\(', 'kind: "quarto-float-tbl"'] + - [] + ensurePdfRegexMatches: + # Caption text should appear (flextable content is image) + - ['Table 1', 'Motor Trend Cars'] + - [] + ensurePdfTextPositions: + - # Table caption in margin, right of body text + # Note: flextable content is image, so only caption text is testable + - subject: "Motor Trend Cars" + relation: rightOf + object: "This tests" + - [] + noErrors: default +--- + +This tests a flextable placed in the margin using `#| column: margin`. + +```{r} +#| column: margin +#| classes: plain +#| echo: false +#| label: tbl-cars-flex +#| tbl-cap: "Motor Trend Cars" + +library(flextable) + +mtcars_mini <- head(mtcars[, 1:4], 8) +mtcars_mini$car <- rownames(mtcars_mini) +mtcars_mini <- mtcars_mini[, c("car", "mpg", "cyl", "disp")] + +flextable(mtcars_mini) |> + set_header_labels(car = "Car", mpg = "MPG", cyl = "Cyl", disp = "Disp") |> + autofit() +``` + +See @tbl-cars-flex for the table in the margin. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-caption-below.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-caption-below.qmd new file mode 100644 index 00000000000..c0af32ad24f --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-caption-below.qmd @@ -0,0 +1,54 @@ +--- +title: "Margin Table with Caption Below" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option produces #notefigure (entire figure in margin) + - ['#notefigure\(', 'kind: "quarto-float-tbl"', 'position: bottom'] + - [] + ensurePdfRegexMatches: + - ['Table 1', 'Large Landmasses'] + - [] + ensurePdfTextPositions: + - # Table in margin, right of body text + - subject: "Large Landmasses" + relation: rightOf + object: "This tests" + - subject: "Borneo" + relation: rightOf + object: "This tests" + # Caption BELOW table content (cap-location: bottom) + - subject: "Borneo" + relation: above + object: "Large Landmasses" + - [] + noErrors: default +--- + +This tests a great_tables table in the margin with caption below using `cap-location="bottom"`. + +```{python} +#| column: margin +#| classes: plain +#| echo: false +#| label: tbl-islands +#| tbl-cap: "Large Landmasses" +#| cap-location: bottom + +from great_tables import GT +from great_tables.data import islands + +( + GT(islands.head(20), rowname_col="name") + .fmt_integer(columns="size") +) +``` + +See @tbl-islands for the table in the margin. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-crossref.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-crossref.qmd new file mode 100644 index 00000000000..7137c733c27 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-crossref.qmd @@ -0,0 +1,58 @@ +--- +title: "Margin Table Cross-ref Test" +papersize: us-letter +grid: + margin-width: 3.5in + gutter-width: 0.25in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option should produce same output as fenced div: #notefigure + # (entire figure including caption should be in margin, like HTML/LaTeX) + - ['#notefigure\(', ''] + - [] + ensurePdfRegexMatches: + - ['Table 1', 'Air Quality'] + - [] + ensurePdfTextPositions: + - # Table column header in margin, right of body text - "O3" unique to table + - subject: "O3" + relation: rightOf + object: "This tests" + - [] + noErrors: default +--- + +This tests cross-references to margin tables using great_tables. + +```{python} +#| column: margin +#| classes: plain +#| echo: false +#| label: tbl-air +#| tbl-cap: "NYC Air Quality" + +from great_tables import GT +from great_tables.data import airquality + +airquality_mini = airquality.head(5) + +( + GT(airquality_mini) + .tab_header(title="Air Quality") + .cols_label( + Ozone="O3", + Solar_R="Solar", + Wind="Wind", + Temp="Temp" + ) +) +``` + +As shown in @tbl-air, the table appears in the margin with a working cross-reference. + +The reference should show "Table 1" or similar. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-fenced-div.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-fenced-div.qmd new file mode 100644 index 00000000000..85dd23b43d4 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-fenced-div.qmd @@ -0,0 +1,54 @@ +--- +title: "Margin Table with great_tables (Fenced Div)" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # GT tables in margin divs use #note( wrapping #figure( + - ['#note\(', 'kind: "quarto-float-tbl"'] + - [] + ensurePdfTextPositions: + - # Table in margin, right of body text + - subject: "Large Landmasses" + relation: rightOf + object: "This tests" + - subject: "Borneo" + relation: rightOf + object: "This tests" + # Caption above table content (default for tables) + - subject: "Large Landmasses" + relation: above + object: "Borneo" + - [] + noErrors: default +--- + +This tests a great_tables table placed in the margin using a fenced div (`.column-margin`). +This approach wraps `#figure` inside `#note` which differs from the cell option approach. + +::: {.column-margin} + +```{python} +#| classes: plain +#| echo: false +#| label: tbl-islands +#| tbl-cap: "Large Landmasses" + +from great_tables import GT +from great_tables.data import islands + +( + GT(islands.head(20), rowname_col="name") + .fmt_integer(columns="size") +) +``` + +::: + +See @tbl-islands for the table in the margin. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-shift.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-shift.qmd new file mode 100644 index 00000000000..14d46120659 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables-shift.qmd @@ -0,0 +1,73 @@ +--- +title: "Margin Table Shift Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['shift: false', 'shift: "avoid"'] + - [] + ensurePdfTextPositions: + - # Both tables in margin, right of body text + - subject: "Fixed Position" + relation: rightOf + object: "Line 1" + - subject: "Avoid Overlap" + relation: rightOf + object: "Line 2" + # First table above second + - subject: "Fixed Position" + relation: above + object: "Avoid Overlap" + - [] + noErrors: default +--- + +This tests shift control on margin tables. + +Line 1 with a fixed table: + +::: {.column-margin shift="false"} + +```{python} +#| classes: plain +#| echo: false +#| label: tbl-fixed +#| tbl-cap: "Fixed Position" + +from great_tables import GT +from great_tables.data import islands + +( + GT(islands.head(10), rowname_col="name") + .fmt_integer(columns="size") +) +``` + +::: + +Line 2 with an avoid table: + +::: {.column-margin shift="avoid"} + +```{python} +#| classes: plain +#| echo: false +#| label: tbl-avoid +#| tbl-cap: "Avoid Overlap" + +from great_tables import GT +from great_tables.data import islands + +( + GT(islands.tail(10), rowname_col="name") + .fmt_integer(columns="size") +) +``` + +::: + +The first table stays fixed, the second only shifts if necessary. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables.qmd new file mode 100644 index 00000000000..62e6e36ce5a --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-great-tables.qmd @@ -0,0 +1,57 @@ +--- +title: "Margin Table with great_tables" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option produces #notefigure (entire figure in margin) + - ['#notefigure\(', 'kind: "quarto-float-tbl"'] + - [] + ensurePdfRegexMatches: + - ['Table 1', 'Large Landmasses'] + - [] + ensurePdfTextPositions: + - # Table in margin, right of body text + - subject: "Large Landmasses" + relation: rightOf + object: "This tests" + - subject: "Borneo" + relation: rightOf + object: "This tests" + # Caption above table content (default for tables) + - subject: "Large Landmasses" + relation: above + object: "Borneo" + - [] + noErrors: default +--- + +This tests a great_tables table placed in the margin using + +``` +#| column: margin +``` + +```{python} +#| column: margin +#| classes: plain +#| echo: false +#| label: tbl-islands +#| tbl-cap: "Large Landmasses" + +from great_tables import GT +from great_tables.data import islands + +( + GT(islands.head(20), rowname_col="name") + .fmt_integer(columns="size") +) +``` + +See @tbl-islands for the table in the margin. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-gt-r-crossref.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-gt-r-crossref.qmd new file mode 100644 index 00000000000..67ea0b122ec --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-gt-r-crossref.qmd @@ -0,0 +1,54 @@ +--- +title: "Margin Table R gt Cross-ref Test" +papersize: us-letter +grid: + margin-width: 3.5in + gutter-width: 0.25in +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option should produce #notefigure (entire figure in margin) + - ['#notefigure\(', ''] + - [] + ensurePdfRegexMatches: + - ['Table 1', 'Air Quality'] + - [] + ensurePdfTextPositions: + - # Table column header in margin, right of body text - "O3" unique to table + - subject: "O3" + relation: rightOf + object: "This tests" + - [] + noErrors: default +--- + +This tests cross-references to margin tables using R gt package. + +```{r} +#| column: margin +#| classes: plain +#| echo: false +#| label: tbl-air-r +#| tbl-cap: "NYC Air Quality" + +library(gt) + +airquality_mini <- head(airquality, 5) + +gt(airquality_mini) |> + tab_header(title = "Air Quality") |> + cols_label( + Ozone = "O3", + Solar.R = "Solar", + Wind = "Wind", + Temp = "Temp" + ) +``` + +As shown in @tbl-air-r, the table appears in the margin with a working cross-reference. + +The reference should show "Table 1" or similar. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-gt-r.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-gt-r.qmd new file mode 100644 index 00000000000..43b7f04e643 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-gt-r.qmd @@ -0,0 +1,58 @@ +--- +title: "Margin Table with R gt" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Cell option produces #notefigure (entire figure in margin) + - ['#notefigure\(', 'kind: "quarto-float-tbl"'] + - [] + ensurePdfRegexMatches: + - ['Table 1', 'Large Landmasses', 'Asia', 'Africa'] + - [] + ensurePdfTextPositions: + - # Table in margin, right of body text + - subject: "Large Landmasses" + relation: rightOf + object: "This tests" + - subject: "Asia" + relation: rightOf + object: "This tests" + # Caption above table content (default for tables) + - subject: "Large Landmasses" + relation: above + object: "Asia" + - [] + noErrors: default +--- + +This tests an R gt table placed in the margin using `#| column: margin`. + +```{r} +#| column: margin +#| classes: plain +#| echo: false +#| label: tbl-islands-r +#| tbl-cap: "Large Landmasses" + +library(gt) +library(dplyr) + +islands_tbl <- tibble( + name = names(islands), + size = islands +) |> + arrange(desc(size)) |> + slice(1:10) + +gt(islands_tbl) |> + fmt_integer(columns = size) +``` + +See @tbl-islands-r for the table in the margin. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/margin-table-simple.qmd b/tests/docs/smoke-all/typst/margin-layout/margin-table-simple.qmd new file mode 100644 index 00000000000..6005cd57c1a --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/margin-table-simple.qmd @@ -0,0 +1,48 @@ +--- +title: "Margin Table Simple Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Table in margin div uses #notefigure( for proper margin placement + - ['#notefigure\(', 'kind: "quarto-float-tbl"'] + - [] + ensurePdfRegexMatches: + - ['Table 1', 'SIMPLE-TBL-CAP', 'Alpha'] + - [] + ensurePdfTextPositions: + - # Table in margin, right of body text + - subject: "SIMPLE-TBL-CAP" + relation: rightOf + object: "This tests" + - subject: "Alpha" + relation: rightOf + object: "This tests" + # Caption above table content (default for tables) + - subject: "SIMPLE-TBL-CAP" + relation: above + object: "Alpha" + - [] + noErrors: default +--- + +This tests a simple markdown table placed in the margin. + +::: {.column-margin} + +| Col1 | Col2 | Col3 | +|-------|-------|-------| +| Alpha | Beta | Gamma | +| Delta | Omega | Zeta | + +: SIMPLE-TBL-CAP {#tbl-margin} + +::: + +See @tbl-margin for the table in the margin. + +More text in the main column to show the layout. diff --git a/tests/docs/smoke-all/typst/margin-layout/shift-avoid.qmd b/tests/docs/smoke-all/typst/margin-layout/shift-avoid.qmd new file mode 100644 index 00000000000..461b7c76f26 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/shift-avoid.qmd @@ -0,0 +1,36 @@ +--- +title: "Shift Avoid Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['shift: "avoid"'] + - [] + ensurePdfTextPositions: + - # All notes in margin, right of body text + - subject: "FIXED" + relation: rightOf + object: "Line with fixed" + - subject: "AVOID1" + relation: rightOf + object: "Line with avoid" + # Vertical ordering: FIXED above AVOID1 above AVOID2 + - subject: "FIXED" + relation: above + object: "AVOID1" + - subject: "AVOID1" + relation: above + object: "AVOID2" + - [] + noErrors: default +--- + +This tests `shift: "avoid"` - notes only shift when hitting fixed elements. + +Line with fixed note [FIXED: anchor point]{.column-margin shift="false"}. +Line with avoid note [AVOID1: I only shift if I hit something fixed]{.column-margin shift="avoid"}. +Line with another avoid [AVOID2: same behavior]{.column-margin shift="avoid"}. diff --git a/tests/docs/smoke-all/typst/margin-layout/shift-fixed.qmd b/tests/docs/smoke-all/typst/margin-layout/shift-fixed.qmd new file mode 100644 index 00000000000..e8514495ed8 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/shift-fixed.qmd @@ -0,0 +1,36 @@ +--- +title: "Shift Fixed Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['shift: false'] + - [] + ensurePdfTextPositions: + - # All notes in margin, right of body text + - subject: "FIXED" + relation: rightOf + object: "Line with fixed" + - subject: "AUTO1" + relation: rightOf + object: "Line with auto" + # Vertical ordering: FIXED above AUTO1 above AUTO2 + - subject: "FIXED" + relation: above + object: "AUTO1" + - subject: "AUTO1" + relation: above + object: "AUTO2" + - [] + noErrors: default +--- + +This tests `shift: false` where the note stays fixed and other notes move around it. + +Line with fixed note [FIXED: I stay exactly where I am placed, others move around me]{.column-margin shift="false"}. +Line with auto note [AUTO1: I should shift down to avoid the fixed note above]{.column-margin}. +Line with another auto [AUTO2: I shift down further]{.column-margin}. diff --git a/tests/docs/smoke-all/typst/margin-layout/shift-ignore.qmd b/tests/docs/smoke-all/typst/margin-layout/shift-ignore.qmd new file mode 100644 index 00000000000..948435eed6f --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/shift-ignore.qmd @@ -0,0 +1,29 @@ +--- +title: "Shift Ignore Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['shift: "ignore"'] + - [] + ensurePdfTextPositions: + - # All notes in margin, right of body text + - subject: "IGNORE1" + relation: rightOf + object: "Line with ignore" + - subject: "AUTO1" + relation: rightOf + object: "Line with auto" + - [] + noErrors: default +--- + +This tests `shift: "ignore"` - notes stay fixed and CAN overlap (others don't avoid them). + +Line with ignore note [IGNORE1: I stay put and others don't try to avoid me]{.column-margin shift="ignore"}. +Line with auto note [AUTO1: I don't see the ignore note, may overlap]{.column-margin}. +Line with another ignore [IGNORE2: We might overlap each other]{.column-margin shift="ignore"}. diff --git a/tests/docs/smoke-all/typst/margin-layout/shift-mixed.qmd b/tests/docs/smoke-all/typst/margin-layout/shift-mixed.qmd new file mode 100644 index 00000000000..09e71405759 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/shift-mixed.qmd @@ -0,0 +1,40 @@ +--- +title: "Mixed Shift Modes Test" +papersize: us-letter +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['shift: false', 'shift: auto', 'shift: "avoid"', 'shift: "ignore"'] + - [] + ensurePdfTextPositions: + - # Positive assertions + - subject: "FIXED" + relation: above + object: "AUTO1" + - subject: "AUTO1" + relation: above + object: "AVOID" + - subject: "AVOID" + relation: above + object: "AUTO2" + - subject: "FIXED" + relation: rightOf + object: "Line 1" + - # Negative assertions - IGNORE overlaps AVOID, so NOT below + - subject: "IGNORE" + relation: below + object: "FIXED" + noErrors: default +--- + +This tests all shift modes interacting in a single document. + +Line 1 [FIXED]{.column-margin shift="false"}. +Line 2 [AUTO1]{.column-margin}. +Line 3 [AVOID]{.column-margin shift="avoid"}. +Line 4 [IGNORE]{.column-margin shift="ignore"}. +Line 5 [AUTO2]{.column-margin}. diff --git a/tests/docs/smoke-all/typst/margin-layout/sidenote-basic.qmd b/tests/docs/smoke-all/typst/margin-layout/sidenote-basic.qmd new file mode 100644 index 00000000000..32de1a35608 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/sidenote-basic.qmd @@ -0,0 +1,27 @@ +--- +title: "Basic Sidenote Test" +papersize: us-letter +reference-location: margin +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['#quarto-sidenote', 'note\(', 'super\('] + - ['#footnote'] + ensurePdfRegexMatches: + - ['SIDENOTE-CONTENT', 'Main text'] + - [] + ensurePdfTextPositions: + - - subject: "SIDENOTE-CONTENT" + relation: rightOf + object: "Main text" + - [] + noErrors: default +--- + +Main text with a sidenote^[SIDENOTE-CONTENT should appear in margin.]. + +More main text continues here after the sidenote. diff --git a/tests/docs/smoke-all/typst/margin-layout/sidenote-multipara.qmd b/tests/docs/smoke-all/typst/margin-layout/sidenote-multipara.qmd new file mode 100644 index 00000000000..be2b74a96d7 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/sidenote-multipara.qmd @@ -0,0 +1,31 @@ +--- +title: "Multi-paragraph Sidenote" +papersize: us-letter +reference-location: margin +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensurePdfRegexMatches: + - ['PARA-ONE', 'PARA-TWO'] + - [] + ensurePdfTextPositions: + - - subject: "PARA-ONE" + relation: rightOf + object: "Text with a long" + - subject: "PARA-ONE" + relation: above + object: "PARA-TWO" + - [] + noErrors: default +--- + +Text with a long sidenote[^1]. + +[^1]: PARA-ONE first paragraph of the sidenote. + + PARA-TWO second paragraph (indented to continue the footnote). + +The multi-paragraph footnote should be converted to a sidenote with both paragraphs. diff --git a/tests/docs/smoke-all/typst/margin-layout/sidenote-multiple.qmd b/tests/docs/smoke-all/typst/margin-layout/sidenote-multiple.qmd new file mode 100644 index 00000000000..b581352685e --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/sidenote-multiple.qmd @@ -0,0 +1,34 @@ +--- +title: "Multiple Sidenotes" +papersize: us-letter +reference-location: margin +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + ensurePdfRegexMatches: + - ['FIRST-NOTE', 'SECOND-NOTE', 'THIRD-NOTE'] + - [] + ensurePdfTextPositions: + - - subject: "FIRST-NOTE" + relation: rightOf + object: "First point" + - subject: "FIRST-NOTE" + relation: above + object: "SECOND-NOTE" + - subject: "SECOND-NOTE" + relation: above + object: "THIRD-NOTE" + - [] + noErrors: default +--- + +First point^[FIRST-NOTE content.]. Second point^[SECOND-NOTE content.]. + +More text in between. + +Third point^[THIRD-NOTE content.]. + +All three sidenotes should appear in the margin with proper numbering (1, 2, 3). diff --git a/tests/docs/smoke-all/typst/margin-layout/test-image.png b/tests/docs/smoke-all/typst/margin-layout/test-image.png new file mode 100644 index 00000000000..a9ec42c984e Binary files /dev/null and b/tests/docs/smoke-all/typst/margin-layout/test-image.png differ diff --git a/tests/docs/smoke-all/typst/margin-layout/test-plot.svg b/tests/docs/smoke-all/typst/margin-layout/test-plot.svg new file mode 100644 index 00000000000..583148da880 --- /dev/null +++ b/tests/docs/smoke-all/typst/margin-layout/test-plot.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 +25 +50 +75 +100 +0 +2 +4 +6 +▲XAXIS▲ +●YAXIS● +★PLOTTITLE + + diff --git a/tests/docs/smoke-all/typst/pandoc-template-features.qmd b/tests/docs/smoke-all/typst/pandoc-template-features.qmd index 479bf4a3e4b..7f28a5d0a59 100644 --- a/tests/docs/smoke-all/typst/pandoc-template-features.qmd +++ b/tests/docs/smoke-all/typst/pandoc-template-features.qmd @@ -34,6 +34,14 @@ format: _quarto: tests: typst: + ensurePdfMetadata: + title: "Test Document" + author: "Alice Smith" + keywords: + - quarto + - typst + - testing + creator: "Typst" ensureTypstFileRegexMatches: - # Patterns that MUST be found # content-to-string function defined (from Pandoc) diff --git a/tests/docs/smoke-all/typst/pdf-text-position-test.qmd b/tests/docs/smoke-all/typst/pdf-text-position-test.qmd new file mode 100644 index 00000000000..cd648582fce --- /dev/null +++ b/tests/docs/smoke-all/typst/pdf-text-position-test.qmd @@ -0,0 +1,45 @@ +--- +title: TITLE_UNIQUE_TEXT +format: typst +header-includes: | + #set page( + header: [PAGEHEADER_UNIQUE_TEXT], + footer: [PAGEFOOTER_UNIQUE_TEXT] + ) +_quarto: + tests: + typst: + ensurePdfTextPositions: + - # Vertical ordering: header < title < h1 < body < footer (y increases downward) + - subject: + text: "PAGEHEADER_UNIQUE_TEXT" + type: "Decoration" + relation: above + object: "TITLE_UNIQUE_TEXT" + - subject: "TITLE_UNIQUE_TEXT" + relation: above + object: "H1_UNIQUE_TEXT" + - subject: "H1_UNIQUE_TEXT" + relation: above + object: "BODY_UNIQUE_TEXT" + - subject: "BODY_UNIQUE_TEXT" + relation: above + object: + text: "PAGEFOOTER_UNIQUE_TEXT" + type: "Decoration" + # Margin content should be to the right of body and on the same row + - subject: "MARGIN_UNIQUE_TEXT" + relation: rightOf + object: "BODY_UNIQUE_TEXT" + - subject: "MARGIN_UNIQUE_TEXT" + relation: topAligned + object: "BODY_UNIQUE_TEXT" +--- + +# H1_UNIQUE_TEXT + +::: {.column-margin} +MARGIN_UNIQUE_TEXT +::: + +BODY_UNIQUE_TEXT is the main paragraph content positioned in the body column. diff --git a/tests/docs/verify/pdf-metadata/fixture.qmd b/tests/docs/verify/pdf-metadata/fixture.qmd new file mode 100644 index 00000000000..a9dcbf83ffa --- /dev/null +++ b/tests/docs/verify/pdf-metadata/fixture.qmd @@ -0,0 +1,17 @@ +--- +title: "PDF Metadata Test Fixture" +author: "Test Author Name" +subject: "Testing PDF Metadata Extraction" +keywords: + - quarto + - typst + - metadata + - testing +format: typst +--- + +# Test Content + +This document tests PDF metadata extraction. + +The title, author, subject, and keywords should all appear in the PDF metadata. diff --git a/tests/docs/verify/pdf-text-position/fixture.qmd b/tests/docs/verify/pdf-text-position/fixture.qmd new file mode 100644 index 00000000000..b6edc7b9116 --- /dev/null +++ b/tests/docs/verify/pdf-text-position/fixture.qmd @@ -0,0 +1,97 @@ +--- +title: FIXTURE_TITLE_TEXT +format: typst +header-includes: | + #set page( + header: [FIXTURE_HEADER_TEXT], + footer: [FIXTURE_FOOTER_TEXT] + ) +--- + +# FIXTURE_H1_TEXT {#sec-main} + +This is the first paragraph. FIXTURE_BODY_P1_TEXT is a unique marker in the first paragraph. + +## FIXTURE_H2_TEXT {#sec-second} + +::: {.column-margin} +FIXTURE_MARGIN_TEXT is in the margin column. +::: + +FIXTURE_BODY_P2_TEXT is a unique marker in the second paragraph, which appears alongside the margin content. + +### FIXTURE_H3_TEXT {#sec-third} + +More body content here. FIXTURE_BODY_P3_TEXT marks the third paragraph. + +## Labeled Elements {#sec-labeled} + +### Figure with Label {#sec-figure} + +![FIXTURE_FIG_CAPTION_TEXT](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjUwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iNTAiIGZpbGw9IiNjY2MiLz48dGV4dCB4PSI1MCIgeT0iMjUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5JbWFnZTwvdGV4dD48L3N2Zz4=){#fig-sample} + +See @fig-sample for the sample figure. + +### Table with Label {#sec-table} + +| Column A | Column B | +|----------|----------| +| FIXTURE_TBL_CELL_A1 | Cell B1 | +| Cell A2 | Cell B2 | + +: FIXTURE_TBL_CAPTION_TEXT {#tbl-data} + +See @tbl-data for the data table. + +### Equation with Label {#sec-equation} + +$$ +E = mc^2 +$$ {#eq-energy} + +FIXTURE_EQ_REF_TEXT: See @eq-energy for the energy equation. + +### Code Listing with Label {#sec-listing} + +```{#lst-hello .python lst-cap="FIXTURE_LST_CAPTION_TEXT"} +print("FIXTURE_CODE_TEXT") +``` + +See @lst-hello for the code listing. + +### Theorem with Label {#sec-theorem} + +::: {#thm-main} +## FIXTURE_THM_TITLE_TEXT + +FIXTURE_THM_BODY_TEXT: This is the main theorem content. +::: + +See @thm-main for the main theorem. + +### Definition with Label {#sec-definition} + +::: {#def-concept} +## FIXTURE_DEF_TITLE_TEXT + +FIXTURE_DEF_BODY_TEXT: This defines an important concept. +::: + +See @def-concept for the definition. + +{{< pagebreak >}} + +# FIXTURE_PAGE2_H1_TEXT {#sec-page2} + +FIXTURE_PAGE2_BODY_TEXT is on the second page for testing cross-page assertions. + +## Raw Typst Labels {#sec-raw-typst} + +```{=typst} +#figure( + rect(width: 50pt, height: 30pt, fill: gray)[FIXTURE_TYPST_FIG_TEXT], + caption: [FIXTURE_TYPST_FIG_CAPTION] +) +``` + +FIXTURE_TYPST_REF_TEXT: See the raw Typst figure above. diff --git a/tests/smoke/extensions/extension-render-typst-templates.test.ts b/tests/smoke/extensions/extension-render-typst-templates.test.ts new file mode 100644 index 00000000000..dbd4807d0ef --- /dev/null +++ b/tests/smoke/extensions/extension-render-typst-templates.test.ts @@ -0,0 +1,77 @@ +/* + * extension-render-typst-templates.test.ts + * + * Copyright (C) 2020-2024 Posit Software, PBC + */ + +import { join } from "../../../src/deno_ral/path.ts"; +import { quarto } from "../../../src/quarto.ts"; +import { ensureDirSync, existsSync } from "../../../src/deno_ral/fs.ts"; +import { testRender } from "../render/render.ts"; +import { removeIfEmptyDir } from "../../../src/core/path.ts"; + +// Set to a local path for development, or null to use GitHub +// Example: "/Users/gordon/src/tt-work/typst-templates" +const LOCAL_TEMPLATES_PATH: string | null = null; + +// GitHub repo where templates are published (templates are subdirectories) +const GITHUB_REPO = "quarto-ext/typst-templates"; +const GITHUB_BRANCH = "@import-and-gather" + +// Template definitions - single source of truth +// Templates with `skip: true` require local typst packages (@local/...) that need +// `quarto typst gather` to bundle. They're skipped until packages are bundled. +const typstTemplates = [ + { name: "ams" }, + { name: "dept-news", skip: false }, // needs @local/dashing-dept-news + { name: "fiction" }, + { name: "ieee" }, + { name: "letter" }, + { name: "poster" }, // needs @local/typst-poster +]; + +// Helper to get the template source for `quarto use template` +function getTemplateSource(name: string): string { + if (LOCAL_TEMPLATES_PATH) { + return join(LOCAL_TEMPLATES_PATH, name); + } + return `${GITHUB_REPO}/${name}${GITHUB_BRANCH}`; +} + +for (const template of typstTemplates.filter((t) => !t.skip)) { + const format = `${template.name}-typst`; + const baseDir = join("docs", "_temp-test-artifacts"); + const dirName = `typst-${template.name}`; + const workingDir = join(baseDir, dirName); + // quarto use template creates a qmd file named after the working directory + const input = join(workingDir, `${dirName}.qmd`); + + testRender(input, format, true, [], { + prereq: () => { + if (existsSync(workingDir)) { + Deno.removeSync(workingDir, { recursive: true }); + } + ensureDirSync(workingDir); + return Promise.resolve(true); + }, + + setup: async () => { + const source = getTemplateSource(template.name); + console.log(`using template: ${source}`); + const wd = Deno.cwd(); + Deno.chdir(workingDir); + await quarto([ + "use", + "template", + source, + "--no-prompt", + ]); + Deno.chdir(wd); + }, + + teardown: async () => { + await Deno.remove(workingDir, { recursive: true }); + removeIfEmptyDir(baseDir); + }, + }); +} diff --git a/tests/smoke/render/render-typst-package-staging.test.ts b/tests/smoke/render/render-typst-package-staging.test.ts new file mode 100644 index 00000000000..defc8301978 --- /dev/null +++ b/tests/smoke/render/render-typst-package-staging.test.ts @@ -0,0 +1,55 @@ +/* + * render-typst-package-staging.test.ts + * + * Tests that typst packages are staged to .quarto/typst/packages/ during render: + * - Built-in packages (marginalia) from quarto resources + * - Extension @preview packages from typst/packages/preview/ directories + * - Extension @local packages from typst/packages/local/ directories + * + * Copyright (C) 2020-2022 Posit Software, PBC + */ + +import { join } from "../../../src/deno_ral/path.ts"; +import { docs } from "../../utils.ts"; +import { testRender } from "./render.ts"; +import { fileExists } from "../../verify.ts"; +import { safeRemoveSync } from "../../../src/core/path.ts"; + +const input = docs("render/typst-package-staging/test.qmd"); +const projectDir = docs("render/typst-package-staging"); +const stagedPackages = join(projectDir, ".quarto/typst/packages"); + +// Verify extension @preview package was staged +const helloPackage = join(stagedPackages, "preview/hello/0.1.0"); +const helloManifest = join(helloPackage, "typst.toml"); + +// Verify extension @local package was staged +const confettiPackage = join(stagedPackages, "local/confetti/0.1.0"); +const confettiManifest = join(confettiPackage, "typst.toml"); + +// Verify built-in marginalia package was staged (triggered by margin note) +const marginaliaPackage = join(stagedPackages, "preview/marginalia/0.3.1"); +const marginaliaManifest = join(marginaliaPackage, "typst.toml"); + +testRender( + input, + "test-ext-typst", + true, // no supporting files + [ + // Extension @preview package + fileExists(helloPackage), + fileExists(helloManifest), + // Extension @local package + fileExists(confettiPackage), + fileExists(confettiManifest), + // Built-in marginalia package + fileExists(marginaliaPackage), + fileExists(marginaliaManifest), + ], + { + teardown: async () => { + // Clean up the .quarto directory + safeRemoveSync(join(projectDir, ".quarto"), { recursive: true }); + }, + }, +); diff --git a/tests/smoke/smoke-all.test.ts b/tests/smoke/smoke-all.test.ts index b5aaafa6897..e49f317d772 100644 --- a/tests/smoke/smoke-all.test.ts +++ b/tests/smoke/smoke-all.test.ts @@ -24,6 +24,8 @@ import { ensureFileRegexMatches, ensureHtmlElements, ensurePdfRegexMatches, + ensurePdfTextPositions, + ensurePdfMetadata, ensureJatsXpath, ensureOdtXpath, ensurePptxRegexMatches, @@ -183,6 +185,8 @@ function resolveTestSpecs( ensureOdtXpath, ensureJatsXpath, ensurePdfRegexMatches, + ensurePdfTextPositions, + ensurePdfMetadata, ensurePptxRegexMatches, ensurePptxXpath, ensurePptxLayout, diff --git a/tests/smoke/typst-gather/.gitignore b/tests/smoke/typst-gather/.gitignore new file mode 100644 index 00000000000..c020d281044 --- /dev/null +++ b/tests/smoke/typst-gather/.gitignore @@ -0,0 +1,8 @@ +# Cached packages created by tests +_extensions/test-format/typst/ +with-config/_extensions/config-format/typst/ +with-local/_extensions/local-format/typst/ + +# Generated config files +typst-gather.toml +with-local/typst-gather.toml.bak \ No newline at end of file diff --git a/tests/smoke/typst-gather/README.md b/tests/smoke/typst-gather/README.md new file mode 100644 index 00000000000..258ea138563 --- /dev/null +++ b/tests/smoke/typst-gather/README.md @@ -0,0 +1,67 @@ +# typst-gather smoke tests + +These tests verify that `quarto call typst-gather` correctly: + +1. Auto-detects Typst template files from `_extension.yml` +2. Scans those files for `@preview` package imports +3. Downloads the packages to `typst/packages/` directory +4. Uses `rootdir` from config file to resolve relative paths +5. Generates config with `--init-config` +6. Copies `@local` packages when configured in `[local]` section +7. Detects `@local` imports when generating config with `--init-config` + +## Test fixtures + +### `_extensions/test-format/` + +Minimal Typst format extension for auto-detection test: + +- `_extension.yml` - Defines template and template-partials +- `template.typ` - Imports `@preview/example:0.1.0` +- `typst-show.typ` - A template partial (no imports) + +### `with-config/` + +Test fixture with explicit `typst-gather.toml` config: + +- `typst-gather.toml` - Config with `rootdir = "_extensions/config-format"` +- `_extensions/config-format/` - Extension directory + +### `with-local/` + +Test fixture for `@local` package support: + +- `typst-gather.toml` - Config with `rootdir` and `[local]` section +- `_extensions/local-format/` - Extension that imports `@local/my-local-pkg` +- `local-packages/my-local-pkg/` - Local typst package source + +## Manual testing + +```bash +# Test auto-detection +cd tests/smoke/typst-gather +quarto call typst-gather + +# Test config with rootdir +cd tests/smoke/typst-gather/with-config +quarto call typst-gather + +# Test @local packages +cd tests/smoke/typst-gather/with-local +quarto call typst-gather + +# Test --init-config +cd tests/smoke/typst-gather +quarto call typst-gather --init-config +``` + +## Cleanup + +To reset the test fixtures: + +```bash +rm -rf _extensions/test-format/typst/packages +rm -rf with-config/_extensions/config-format/typst/packages +rm -rf with-local/_extensions/local-format/typst/packages +rm -f typst-gather.toml +``` diff --git a/tests/smoke/typst-gather/_extensions/test-format/_extension.yml b/tests/smoke/typst-gather/_extensions/test-format/_extension.yml new file mode 100644 index 00000000000..3fa38953716 --- /dev/null +++ b/tests/smoke/typst-gather/_extensions/test-format/_extension.yml @@ -0,0 +1,10 @@ +title: Test Typst Format +author: Quarto Dev Team +version: 1.0.0 +quarto-required: ">=1.6.0" +contributes: + formats: + typst: + template: template.typ + template-partials: + - typst-show.typ diff --git a/tests/smoke/typst-gather/_extensions/test-format/template.typ b/tests/smoke/typst-gather/_extensions/test-format/template.typ new file mode 100644 index 00000000000..aae30413de5 --- /dev/null +++ b/tests/smoke/typst-gather/_extensions/test-format/template.typ @@ -0,0 +1,7 @@ +// Test template that imports a preview package +#import "@preview/example:0.1.0": * + +#let project(title: "", body) = { + set document(title: title) + body +} diff --git a/tests/smoke/typst-gather/_extensions/test-format/typst-show.typ b/tests/smoke/typst-gather/_extensions/test-format/typst-show.typ new file mode 100644 index 00000000000..b74e925710b --- /dev/null +++ b/tests/smoke/typst-gather/_extensions/test-format/typst-show.typ @@ -0,0 +1,4 @@ +// Test partial - no imports needed for this test +#show: project.with( + title: "Test Document" +) diff --git a/tests/smoke/typst-gather/typst-gather.test.ts b/tests/smoke/typst-gather/typst-gather.test.ts new file mode 100644 index 00000000000..523a38c5a3b --- /dev/null +++ b/tests/smoke/typst-gather/typst-gather.test.ts @@ -0,0 +1,220 @@ +import { testQuartoCmd, Verify } from "../../test.ts"; +import { assert } from "testing/asserts"; +import { existsSync } from "../../../src/deno_ral/fs.ts"; + +// Test 1: Auto-detection from _extension.yml +const verifyPackagesCreated: Verify = { + name: "Verify typst/packages directory was created", + verify: async () => { + const packagesDir = "_extensions/test-format/typst/packages"; + assert( + existsSync(packagesDir), + `Expected typst/packages directory not found: ${packagesDir}`, + ); + }, +}; + +const verifyExamplePackageCached: Verify = { + name: "Verify @preview/example package was cached", + verify: async () => { + const packageDir = + "_extensions/test-format/typst/packages/preview/example/0.1.0"; + assert( + existsSync(packageDir), + `Expected cached package not found: ${packageDir}`, + ); + + // Verify typst.toml exists in the package + const manifestPath = `${packageDir}/typst.toml`; + assert( + existsSync(manifestPath), + `Expected package manifest not found: ${manifestPath}`, + ); + }, +}; + +testQuartoCmd( + "call", + ["typst-gather"], + [verifyPackagesCreated, verifyExamplePackageCached], + { + cwd: () => "smoke/typst-gather", + }, + "typst-gather caches preview packages from extension templates", +); + +// Test 2: Config file with rootdir +const verifyConfigPackagesCreated: Verify = { + name: "Verify typst/packages directory was created via config", + verify: async () => { + const packagesDir = "_extensions/config-format/typst/packages"; + assert( + existsSync(packagesDir), + `Expected typst/packages directory not found: ${packagesDir}`, + ); + }, +}; + +const verifyConfigExamplePackageCached: Verify = { + name: "Verify @preview/example package was cached via config", + verify: async () => { + const packageDir = + "_extensions/config-format/typst/packages/preview/example/0.1.0"; + assert( + existsSync(packageDir), + `Expected cached package not found: ${packageDir}`, + ); + + const manifestPath = `${packageDir}/typst.toml`; + assert( + existsSync(manifestPath), + `Expected package manifest not found: ${manifestPath}`, + ); + }, +}; + +testQuartoCmd( + "call", + ["typst-gather"], + [verifyConfigPackagesCreated, verifyConfigExamplePackageCached], + { + cwd: () => "smoke/typst-gather/with-config", + }, + "typst-gather uses rootdir from config file", +); + +// Test 3: --init-config generates config file +const verifyInitConfigCreated: Verify = { + name: "Verify typst-gather.toml was created", + verify: async () => { + assert( + existsSync("typst-gather.toml"), + "Expected typst-gather.toml to be created", + ); + + // Read and verify content has rootdir + const content = Deno.readTextFileSync("typst-gather.toml"); + assert( + content.includes("rootdir"), + "Expected typst-gather.toml to contain rootdir", + ); + assert( + content.includes("_extensions/test-format"), + "Expected rootdir to point to extension directory", + ); + }, +}; + +testQuartoCmd( + "call", + ["typst-gather", "--init-config"], + [verifyInitConfigCreated], + { + cwd: () => "smoke/typst-gather", + teardown: async () => { + // Clean up generated config file + try { + Deno.removeSync("typst-gather.toml"); + } catch { + // Ignore if already removed + } + }, + }, + "typst-gather --init-config generates config with rootdir", +); + +// Test 4: @local package is copied when [local] section is configured +const verifyLocalPackageCopied: Verify = { + name: "Verify @local/my-local-pkg was copied", + verify: async () => { + const packageDir = + "_extensions/local-format/typst/packages/local/my-local-pkg/0.1.0"; + assert( + existsSync(packageDir), + `Expected local package not found: ${packageDir}`, + ); + + const manifestPath = `${packageDir}/typst.toml`; + assert( + existsSync(manifestPath), + `Expected package manifest not found: ${manifestPath}`, + ); + + const libPath = `${packageDir}/lib.typ`; + assert(existsSync(libPath), `Expected lib.typ not found: ${libPath}`); + }, +}; + +testQuartoCmd( + "call", + ["typst-gather"], + [verifyLocalPackageCopied], + { + cwd: () => "smoke/typst-gather/with-local", + teardown: async () => { + // Clean up copied packages + try { + Deno.removeSync("_extensions/local-format/typst", { recursive: true }); + } catch { + // Ignore if already removed + } + }, + }, + "typst-gather copies @local packages when configured", +); + +// Test 5: --init-config detects @local imports and generates [local] section +const verifyInitConfigWithLocal: Verify = { + name: "Verify --init-config detects @local imports", + verify: async () => { + assert( + existsSync("typst-gather.toml"), + "Expected typst-gather.toml to be created", + ); + + const content = Deno.readTextFileSync("typst-gather.toml"); + assert( + content.includes("[local]"), + "Expected typst-gather.toml to contain [local] section", + ); + assert( + content.includes("my-local-pkg"), + "Expected typst-gather.toml to reference my-local-pkg", + ); + assert( + content.includes("@local/my-local-pkg"), + "Expected typst-gather.toml to show found @local import", + ); + }, +}; + +testQuartoCmd( + "call", + ["typst-gather", "--init-config"], + [verifyInitConfigWithLocal], + { + cwd: () => "smoke/typst-gather/with-local", + setup: async () => { + // Remove existing config so --init-config can run + try { + Deno.renameSync("typst-gather.toml", "typst-gather.toml.bak"); + } catch { + // Ignore if doesn't exist + } + }, + teardown: async () => { + // Restore original config and clean up generated one + try { + Deno.removeSync("typst-gather.toml"); + } catch { + // Ignore + } + try { + Deno.renameSync("typst-gather.toml.bak", "typst-gather.toml"); + } catch { + // Ignore + } + }, + }, + "typst-gather --init-config detects @local imports", +); diff --git a/tests/smoke/typst-gather/with-config/_extensions/config-format/_extension.yml b/tests/smoke/typst-gather/with-config/_extensions/config-format/_extension.yml new file mode 100644 index 00000000000..87da6fc61fd --- /dev/null +++ b/tests/smoke/typst-gather/with-config/_extensions/config-format/_extension.yml @@ -0,0 +1,9 @@ +title: Test Format with Config +author: Quarto Dev Team +version: 1.0.0 +quarto-required: ">=1.6.0" +contributes: + formats: + typst: + template-partials: + - typst-template.typ diff --git a/tests/smoke/typst-gather/with-config/_extensions/config-format/typst-template.typ b/tests/smoke/typst-gather/with-config/_extensions/config-format/typst-template.typ new file mode 100644 index 00000000000..aae30413de5 --- /dev/null +++ b/tests/smoke/typst-gather/with-config/_extensions/config-format/typst-template.typ @@ -0,0 +1,7 @@ +// Test template that imports a preview package +#import "@preview/example:0.1.0": * + +#let project(title: "", body) = { + set document(title: title) + body +} diff --git a/tests/smoke/typst-gather/with-config/typst-gather.toml b/tests/smoke/typst-gather/with-config/typst-gather.toml new file mode 100644 index 00000000000..7822f3da40c --- /dev/null +++ b/tests/smoke/typst-gather/with-config/typst-gather.toml @@ -0,0 +1,4 @@ +# Test config with rootdir +rootdir = "_extensions/config-format" +destination = "typst/packages" +discover = ["typst-template.typ"] diff --git a/tests/smoke/typst-gather/with-local/_extensions/local-format/_extension.yml b/tests/smoke/typst-gather/with-local/_extensions/local-format/_extension.yml new file mode 100644 index 00000000000..c24536fd266 --- /dev/null +++ b/tests/smoke/typst-gather/with-local/_extensions/local-format/_extension.yml @@ -0,0 +1,9 @@ +title: Test Format with Local Import +author: Quarto Dev Team +version: 1.0.0 +quarto-required: ">=1.6.0" +contributes: + formats: + typst: + template-partials: + - typst-template.typ diff --git a/tests/smoke/typst-gather/with-local/_extensions/local-format/typst-template.typ b/tests/smoke/typst-gather/with-local/_extensions/local-format/typst-template.typ new file mode 100644 index 00000000000..ba296f3a8cc --- /dev/null +++ b/tests/smoke/typst-gather/with-local/_extensions/local-format/typst-template.typ @@ -0,0 +1,8 @@ +// Test template that imports a local package +#import "@local/my-local-pkg:0.1.0": hello + +#let project(title: "", body) = { + set document(title: title) + hello() + body +} diff --git a/tests/smoke/typst-gather/with-local/local-packages/my-local-pkg/lib.typ b/tests/smoke/typst-gather/with-local/local-packages/my-local-pkg/lib.typ new file mode 100644 index 00000000000..85d49b57aff --- /dev/null +++ b/tests/smoke/typst-gather/with-local/local-packages/my-local-pkg/lib.typ @@ -0,0 +1,2 @@ +// Test local package +#let hello() = "Hello from local package!" diff --git a/tests/smoke/typst-gather/with-local/local-packages/my-local-pkg/typst.toml b/tests/smoke/typst-gather/with-local/local-packages/my-local-pkg/typst.toml new file mode 100644 index 00000000000..0cd752d06b8 --- /dev/null +++ b/tests/smoke/typst-gather/with-local/local-packages/my-local-pkg/typst.toml @@ -0,0 +1,7 @@ +[package] +name = "my-local-pkg" +version = "0.1.0" +entrypoint = "lib.typ" +authors = ["Test"] +license = "MIT" +description = "Test local package" diff --git a/tests/smoke/typst-gather/with-local/typst-gather.toml b/tests/smoke/typst-gather/with-local/typst-gather.toml new file mode 100644 index 00000000000..20fce22523d --- /dev/null +++ b/tests/smoke/typst-gather/with-local/typst-gather.toml @@ -0,0 +1,7 @@ +# Test config with local package +rootdir = "_extensions/local-format" +destination = "typst/packages" +discover = ["typst-template.typ"] + +[local] +my-local-pkg = "local-packages/my-local-pkg" diff --git a/tests/smoke/verify/pdf-metadata.test.ts b/tests/smoke/verify/pdf-metadata.test.ts new file mode 100644 index 00000000000..31ad2c775cb --- /dev/null +++ b/tests/smoke/verify/pdf-metadata.test.ts @@ -0,0 +1,152 @@ +/* + * pdf-metadata.test.ts + * + * Tests for the ensurePdfMetadata verify predicate. + * Renders a fixture document and runs various assertions including expected failures. + * + * Copyright (C) 2020-2025 Posit Software, PBC + */ + +import { testQuartoCmd } from "../../test.ts"; +import { ensurePdfMetadata } from "../../verify-pdf-metadata.ts"; +import { assert } from "testing/asserts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { safeRemoveSync, safeExistsSync } from "../../../src/core/path.ts"; + +const fixtureDir = "docs/verify/pdf-metadata"; +const fixtureQmd = join(fixtureDir, "fixture.qmd"); +const fixturePdf = join(fixtureDir, "fixture.pdf"); + +/** + * Helper to assert that a function throws with error message matching a pattern + */ +async function assertThrowsWithPattern( + fn: () => Promise, + pattern: RegExp, + description: string, +) { + let threw = false; + let errorMessage = ""; + try { + await fn(); + } catch (e) { + threw = true; + errorMessage = e instanceof Error ? e.message : String(e); + } + + assert(threw, `Expected to throw for: ${description}`); + assert( + pattern.test(errorMessage), + `Error message "${errorMessage}" did not match pattern ${pattern} for: ${description}`, + ); +} + +// Test: Render fixture and run assertions +testQuartoCmd("render", [fixtureQmd, "--to", "typst"], [], { + teardown: async () => { + // Run the test assertions after render completes + await runPositiveTests(); + await runExpectedFailureTests(); + + // Cleanup + if (safeExistsSync(fixturePdf)) { + safeRemoveSync(fixturePdf); + } + }, +}); + +/** + * Test positive assertions that should pass + */ +async function runPositiveTests() { + // Test 1: Title contains expected text + const titleTest = ensurePdfMetadata(fixturePdf, { + title: "PDF Metadata Test Fixture", + }); + await titleTest.verify([]); + + // Test 2: Author contains expected text + const authorTest = ensurePdfMetadata(fixturePdf, { + author: "Test Author Name", + }); + await authorTest.verify([]); + + // Test 3: Keywords contain expected values (as array) + const keywordsTest = ensurePdfMetadata(fixturePdf, { + keywords: ["quarto", "typst"], + }); + await keywordsTest.verify([]); + + // Test 4: Title matches regex + const regexTest = ensurePdfMetadata(fixturePdf, { + title: /PDF.*Fixture/, + }); + await regexTest.verify([]); + + // Test 5: Multiple fields at once + const multiTest = ensurePdfMetadata(fixturePdf, { + title: "Metadata", + author: "Author", + keywords: ["testing"], + }); + await multiTest.verify([]); + + // Test 6: Creator field (should contain "Typst" since we render with Typst) + const creatorTest = ensurePdfMetadata(fixturePdf, { + creator: /typst/i, + }); + await creatorTest.verify([]); +} + +/** + * Test expected failures - each should throw with specific error messages + */ +async function runExpectedFailureTests() { + // Error 1: Title mismatch + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfMetadata(fixturePdf, { + title: "NONEXISTENT_TITLE_12345", + }); + await predicate.verify([]); + }, + /title.*expected.*NONEXISTENT_TITLE_12345/i, + "Title mismatch error", + ); + + // Error 2: Author mismatch + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfMetadata(fixturePdf, { + author: "NONEXISTENT_AUTHOR_12345", + }); + await predicate.verify([]); + }, + /author.*expected.*NONEXISTENT_AUTHOR_12345/i, + "Author mismatch error", + ); + + // Error 3: Keywords mismatch + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfMetadata(fixturePdf, { + keywords: ["NONEXISTENT_KEYWORD_12345"], + }); + await predicate.verify([]); + }, + /keywords.*expected.*NONEXISTENT_KEYWORD_12345/i, + "Keywords mismatch error", + ); + + // Error 4: Regex mismatch + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfMetadata(fixturePdf, { + title: /^EXACT_NONEXISTENT$/, + }); + await predicate.verify([]); + }, + /title.*expected.*match/i, + "Regex mismatch error", + ); +} diff --git a/tests/smoke/verify/pdf-text-position.test.ts b/tests/smoke/verify/pdf-text-position.test.ts new file mode 100644 index 00000000000..9884f625dff --- /dev/null +++ b/tests/smoke/verify/pdf-text-position.test.ts @@ -0,0 +1,216 @@ +/* + * pdf-text-position.test.ts + * + * Tests for the ensurePdfTextPositions verify predicate. + * Renders a fixture document and runs various assertions including expected failures. + * + * Copyright (C) 2020-2025 Posit Software, PBC + */ + +import { testQuartoCmd } from "../../test.ts"; +import { ensurePdfTextPositions } from "../../verify-pdf-text-position.ts"; +import { assert, AssertionError } from "testing/asserts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { safeRemoveSync, safeExistsSync } from "../../../src/core/path.ts"; + +const fixtureDir = "docs/verify/pdf-text-position"; +const fixtureQmd = join(fixtureDir, "fixture.qmd"); +const fixturePdf = join(fixtureDir, "fixture.pdf"); + +/** + * Helper to assert that a function throws with error message matching a pattern + */ +async function assertThrowsWithPattern( + fn: () => Promise, + pattern: RegExp, + description: string, +) { + let threw = false; + let errorMessage = ""; + try { + await fn(); + } catch (e) { + threw = true; + errorMessage = e instanceof Error ? e.message : String(e); + } + + assert(threw, `Expected to throw for: ${description}`); + assert( + pattern.test(errorMessage), + `Error message "${errorMessage}" did not match pattern ${pattern} for: ${description}`, + ); +} + +// Test: Render fixture and run assertions +testQuartoCmd("render", [fixtureQmd, "--to", "typst"], [], { + teardown: async () => { + // Run the test assertions after render completes + await runPositiveTests(); + await runExpectedFailureTests(); + await runSemanticTagTests(); + + // Cleanup + if (safeExistsSync(fixturePdf)) { + safeRemoveSync(fixturePdf); + } + }, +}); + +/** + * Test positive assertions that should pass + */ +async function runPositiveTests() { + // Test 1: Basic vertical ordering (header < title < h1 < body < footer) + // Note: Headers and footers are page decorations without MCIDs, use type: "Decoration" + const verticalOrdering = ensurePdfTextPositions(fixturePdf, [ + { + subject: { text: "FIXTURE_HEADER_TEXT", type: "Decoration" }, + relation: "above", + object: "FIXTURE_TITLE_TEXT", + }, + { subject: "FIXTURE_TITLE_TEXT", relation: "above", object: "FIXTURE_H1_TEXT" }, + { subject: "FIXTURE_H1_TEXT", relation: "above", object: "FIXTURE_BODY_P1_TEXT" }, + { + subject: "FIXTURE_BODY_P1_TEXT", + relation: "above", + object: { text: "FIXTURE_FOOTER_TEXT", type: "Decoration" }, + }, + ]); + await verticalOrdering.verify([]); + + // Test 2: Margin positioning - use topAligned since semantic bbox may span full width + // Note: rightOf may not work with semantic bboxes because body paragraph's bbox + // may include the full content width + const marginPositioning = ensurePdfTextPositions(fixturePdf, [ + { subject: "FIXTURE_MARGIN_TEXT", relation: "topAligned", object: "FIXTURE_BODY_P2_TEXT" }, + ]); + await marginPositioning.verify([]); + + // Test 3: Heading hierarchy + const headingHierarchy = ensurePdfTextPositions(fixturePdf, [ + { subject: "FIXTURE_H1_TEXT", relation: "above", object: "FIXTURE_H2_TEXT" }, + { subject: "FIXTURE_H2_TEXT", relation: "above", object: "FIXTURE_H3_TEXT" }, + ]); + await headingHierarchy.verify([]); +} + +/** + * Test expected failures - each should throw with specific error messages + */ +async function runExpectedFailureTests() { + // Error 1: Text not found + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfTextPositions(fixturePdf, [ + { subject: "NONEXISTENT_TEXT_12345", relation: "above", object: "FIXTURE_BODY_P1_TEXT" }, + ]); + await predicate.verify([]); + }, + /Text not found.*NONEXISTENT_TEXT_12345/, + "Text not found error", + ); + + // Error 1b: Ambiguous text (appears multiple times) + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfTextPositions(fixturePdf, [ + // "paragraph" appears in multiple places in the fixture + { subject: "paragraph", relation: "above", object: "FIXTURE_BODY_P1_TEXT" }, + ]); + await predicate.verify([]); + }, + /paragraph.*ambiguous.*matches/i, + "Ambiguous text error", + ); + + // Error 2: Unknown relation + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfTextPositions(fixturePdf, [ + { subject: "FIXTURE_H1_TEXT", relation: "invalidRelation", object: "FIXTURE_BODY_P1_TEXT" }, + ]); + await predicate.verify([]); + }, + /Unknown relation.*invalidRelation/, + "Unknown relation error", + ); + + // Error 3: Different pages - comparing items on different pages should fail + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfTextPositions(fixturePdf, [ + { subject: "FIXTURE_H1_TEXT", relation: "above", object: "FIXTURE_PAGE2_BODY_TEXT" }, + ]); + await predicate.verify([]); + }, + /Cannot compare positions.*page \d+.*page \d+/, + "Different pages error", + ); + + // Error 4: Position assertion failed (wrong relation) + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfTextPositions(fixturePdf, [ + // Footer is BELOW header, not above (both are Decorations) + { + subject: { text: "FIXTURE_FOOTER_TEXT", type: "Decoration" }, + relation: "above", + object: { text: "FIXTURE_HEADER_TEXT", type: "Decoration" }, + }, + ]); + await predicate.verify([]); + }, + /Position assertion failed.*FIXTURE_FOOTER_TEXT.*NOT.*above/, + "Position assertion failed error", + ); + + // Error 5: Negative assertion unexpectedly true + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfTextPositions( + fixturePdf, + [], // No positive assertions + [ + // This IS true, so negative assertion should fail + { subject: "FIXTURE_H1_TEXT", relation: "above", object: "FIXTURE_H2_TEXT" }, + ], + ); + await predicate.verify([]); + }, + /Negative assertion failed.*FIXTURE_H1_TEXT.*IS.*above/, + "Negative assertion unexpectedly true error", + ); + + // Error 6: Tag type mismatch (wrong semantic type) + await assertThrowsWithPattern( + async () => { + const predicate = ensurePdfTextPositions(fixturePdf, [ + // H1 is not a Figure + { subject: { text: "FIXTURE_H1_TEXT", type: "Figure" }, relation: "above", object: "FIXTURE_BODY_P1_TEXT" }, + ]); + await predicate.verify([]); + }, + /Tag type mismatch.*FIXTURE_H1_TEXT.*expected Figure.*got H1/, + "Tag type mismatch error", + ); +} + +/** + * Test semantic tag type assertions + */ +async function runSemanticTagTests() { + // Test: Correct semantic types should pass + const correctTypes = ensurePdfTextPositions(fixturePdf, [ + { + subject: { text: "FIXTURE_H1_TEXT", type: "H1" }, + relation: "above", + object: { text: "FIXTURE_BODY_P1_TEXT", type: "P" }, + }, + { + subject: { text: "FIXTURE_H2_TEXT", type: "H2" }, + relation: "above", + object: { text: "FIXTURE_H3_TEXT", type: "H3" }, + }, + ]); + await correctTypes.verify([]); +} diff --git a/tests/verify-pdf-metadata.ts b/tests/verify-pdf-metadata.ts new file mode 100644 index 00000000000..bd0e789bc85 --- /dev/null +++ b/tests/verify-pdf-metadata.ts @@ -0,0 +1,132 @@ +/* + * verify-pdf-metadata.ts + * + * PDF metadata verification using pdfjs-dist. + * Extracts and verifies PDF document metadata (title, author, keywords, etc.). + * + * Copyright (C) 2020-2025 Posit Software, PBC + */ + +import { assert } from "testing/asserts"; +import { ExecuteOutput, Verify } from "./test.ts"; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * PDF metadata fields that can be verified. + * All fields are optional - only specified fields will be checked. + */ +export interface PdfMetadataAssertion { + title?: string | RegExp; + author?: string | RegExp; + subject?: string | RegExp; + keywords?: string | RegExp | string[]; + creator?: string | RegExp; + producer?: string | RegExp; + creationDate?: string | RegExp | Date; + modDate?: string | RegExp | Date; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Match a value against a string, RegExp, or array of strings. + */ +function matchValue( + actual: string | undefined | null, + expected: string | RegExp | string[] | Date | undefined, + fieldName: string, +): string | null { + if (expected === undefined) return null; + + const actualStr = actual ?? ""; + + if (expected instanceof RegExp) { + if (!expected.test(actualStr)) { + return `${fieldName}: expected to match ${expected}, got "${actualStr}"`; + } + } else if (expected instanceof Date) { + // For dates, just check if the actual contains the expected date components + const expectedStr = expected.toISOString().slice(0, 10); // YYYY-MM-DD + if (!actualStr.includes(expectedStr)) { + return `${fieldName}: expected to contain date ${expectedStr}, got "${actualStr}"`; + } + } else if (Array.isArray(expected)) { + // For arrays (keywords), check if all expected values are present + for (const keyword of expected) { + if (!actualStr.toLowerCase().includes(keyword.toLowerCase())) { + return `${fieldName}: expected to contain "${keyword}", got "${actualStr}"`; + } + } + } else { + // String comparison (case-insensitive contains) + if (!actualStr.toLowerCase().includes(expected.toLowerCase())) { + return `${fieldName}: expected to contain "${expected}", got "${actualStr}"`; + } + } + + return null; +} + +// ============================================================================ +// Main Predicate +// ============================================================================ + +/** + * Verify PDF metadata fields match expected values. + * Uses pdfjs-dist to extract metadata from PDF files. + * + * @param file - Path to the PDF file + * @param assertions - Metadata fields to verify + * @returns Verify object for test framework + */ +export const ensurePdfMetadata = ( + file: string, + assertions: PdfMetadataAssertion, +): Verify => { + return { + name: `Inspecting ${file} for PDF metadata`, + verify: async (_output: ExecuteOutput[]) => { + const errors: string[] = []; + + // Load PDF with pdfjs-dist + // deno-lint-ignore no-explicit-any + const pdfjsLib = await import("pdfjs-dist") as any; + const buffer = await Deno.readFile(file); + const doc = await pdfjsLib.getDocument({ data: buffer }).promise; + + // Get metadata + const { info } = await doc.getMetadata(); + + // Verify each specified field + const checks = [ + matchValue(info?.Title, assertions.title, "title"), + matchValue(info?.Author, assertions.author, "author"), + matchValue(info?.Subject, assertions.subject, "subject"), + matchValue(info?.Keywords, assertions.keywords, "keywords"), + matchValue(info?.Creator, assertions.creator, "creator"), + matchValue(info?.Producer, assertions.producer, "producer"), + matchValue(info?.CreationDate, assertions.creationDate, "creationDate"), + matchValue(info?.ModDate, assertions.modDate, "modDate"), + ]; + + for (const error of checks) { + if (error) { + errors.push(error); + } + } + + // Report errors + if (errors.length > 0) { + assert( + false, + `PDF metadata assertions failed in ${file}:\n${errors.map((e, i) => ` ${i + 1}. ${e}`).join("\n")}`, + ); + } + }, + }; +}; diff --git a/tests/verify-pdf-text-position.ts b/tests/verify-pdf-text-position.ts new file mode 100644 index 00000000000..6d8116519f8 --- /dev/null +++ b/tests/verify-pdf-text-position.ts @@ -0,0 +1,594 @@ +/* + * verify-pdf-text-position.ts + * + * PDF text position verification using semantic structure tree. + * Uses pdfjs-dist directly to access MCIDs and structure tree. + * + * REQUIREMENTS: + * This module requires tagged PDFs with PDF 1.4+ structure tree support. + * Tagged PDFs contain Marked Content Identifiers (MCIDs) that link text + * content to semantic structure elements (P, H1, Figure, Table, etc.). + * + * Currently confirmed working: + * - Typst: Produces tagged PDFs by default + * + * Not yet working: + * - LaTeX: Requires \DocumentMetadata{} before \documentclass for tagging, + * which Quarto doesn't currently support. When LaTeX tagged PDF support + * is available, this module should work with minimal changes since we + * use only basic PDF 1.4 tagged structure features. + * - ConTeXt: Pandoc supports +tagging extension, but Quarto's context + * format doesn't compile to PDF. + * + * SPECIAL TYPES: + * - type: "Decoration" - Use for untagged page elements like headers, footers, + * page numbers, and other decorations. These use text item bounds directly + * instead of requiring MCID/structure tree support. + * + * Copyright (C) 2020-2025 Posit Software, PBC + */ + +import { assert } from "testing/asserts"; +import { ExecuteOutput, Verify } from "./test.ts"; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +// Extended subject/object selector +// Note: Label/ID checking is not supported because: +// 1. Typst does not write labels to PDF StructElem /ID attributes (labels become +// named destinations for links, but not structure element identifiers) +// 2. Even if IDs were present, pdf.js doesn't expose /ID through getStructTree() +interface TextSelector { + text: string; + type?: string; // PDF 1.4 tag: P, H1, H2, Figure, Table, Span, etc. +} + +// Assertion format +export interface PdfTextPositionAssertion { + subject: string | TextSelector; + relation?: string; // Optional for tag-only assertions + object?: string | TextSelector; // Optional for tag-only assertions + tolerance?: number; // Default: 2pt +} + +// Computed bounding box +interface BBox { + x: number; + y: number; + width: number; + height: number; + page: number; +} + +// Internal: text item with MCID tracking +interface MarkedTextItem { + str: string; + x: number; + y: number; + width: number; + height: number; + mcid: string | null; // e.g., "p2R_mc0" + page: number; +} + +// Structure tree node (from pdfjs-dist) +interface StructTreeNode { + role: string; + children?: (StructTreeNode | StructTreeContent)[]; + alt?: string; + lang?: string; +} + +interface StructTreeContent { + type: "content" | "object" | "annotation"; + id: string; +} + +// Text content item types from pdfjs-dist +interface TextItem { + str: string; + dir: string; + transform: number[]; + width: number; + height: number; + fontName: string; + hasEOL: boolean; +} + +interface TextMarkedContent { + type: "beginMarkedContent" | "beginMarkedContentProps" | "endMarkedContent"; + id?: string; + tag?: string; +} + +// Internal: resolved selector with computed bounds +interface ResolvedSelector { + selector: TextSelector; + textItem: MarkedTextItem; + structNode: StructTreeNode | null; + bbox: BBox; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_ALIGNMENT_TOLERANCE = 2; + +// ============================================================================ +// Relation Predicates +// ============================================================================ + +// Coordinate system: origin at top-left, y increases downward +type RelationFn = (a: BBox, b: BBox, tolerance: number) => boolean; + +const pdfPositionRelations: Record = { + // Directional predicates (tolerance not used) + leftOf: (a, b) => a.x + a.width < b.x, + rightOf: (a, b) => a.x > b.x + b.width, + above: (a, b) => a.y + a.height < b.y, + below: (a, b) => a.y > b.y + b.height, + + // Alignment predicates (use tolerance) + leftAligned: (a, b, tol) => Math.abs(a.x - b.x) <= tol, + rightAligned: (a, b, tol) => Math.abs((a.x + a.width) - (b.x + b.width)) <= tol, + topAligned: (a, b, tol) => Math.abs(a.y - b.y) <= tol, + bottomAligned: (a, b, tol) => Math.abs((a.y + a.height) - (b.y + b.height)) <= tol, +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function normalizeSelector(s: string | TextSelector): TextSelector { + if (typeof s === "string") { + return { text: s }; + } + return s; +} + +function isStructTreeContent(node: StructTreeNode | StructTreeContent): node is StructTreeContent { + return "type" in node && (node.type === "content" || node.type === "object" || node.type === "annotation"); +} + +function isTextItem(item: TextItem | TextMarkedContent): item is TextItem { + return "str" in item && typeof item.str === "string"; +} + +function isTextMarkedContent(item: TextItem | TextMarkedContent): item is TextMarkedContent { + return "type" in item && typeof item.type === "string"; +} + +/** + * Extract MarkedTextItem[] from pdfjs getTextContent result. + * Tracks current MCID as we iterate through interleaved items. + */ +function extractMarkedTextItems( + items: (TextItem | TextMarkedContent)[], + pageNum: number, + pageHeight: number, +): MarkedTextItem[] { + const result: MarkedTextItem[] = []; + let currentMcid: string | null = null; + + for (const item of items) { + if (isTextMarkedContent(item)) { + if (item.type === "beginMarkedContentProps" && item.id) { + currentMcid = item.id; + } else if (item.type === "endMarkedContent") { + currentMcid = null; + } + } else if (isTextItem(item)) { + // Transform: [scaleX, skewX, skewY, scaleY, translateX, translateY] + const tm = item.transform; + const x = tm[4]; + // Convert from PDF coordinates (bottom-left origin) to top-left origin + const y = pageHeight - tm[5]; + const height = Math.sqrt(tm[2] * tm[2] + tm[3] * tm[3]); + + result.push({ + str: item.str, + x, + y, + width: item.width, + height, + mcid: currentMcid, + page: pageNum, + }); + } + } + + return result; +} + +/** + * Recursively build MCID -> StructNode map from structure tree. + * Returns the struct node that directly contains the MCID content. + */ +function buildMcidStructMap( + tree: StructTreeNode | null, + map: Map = new Map(), + parentNode: StructTreeNode | null = null, +): Map { + if (!tree) return map; + + for (const child of tree.children ?? []) { + if (isStructTreeContent(child)) { + if (child.type === "content" && child.id) { + // Map MCID to the parent struct node (the semantic element) + map.set(child.id, parentNode ?? tree); + } + } else { + // Recurse into child struct nodes + buildMcidStructMap(child, map, child); + } + } + + return map; +} + +/** + * Collect only direct MCIDs under a structure node (non-recursive). + * Does not descend into child structure nodes. + */ +function collectDirectMcids(node: StructTreeNode): string[] { + const mcids: string[] = []; + + for (const child of node.children ?? []) { + if (isStructTreeContent(child)) { + if (child.type === "content" && child.id) { + mcids.push(child.id); + } + } + // Do NOT recurse into child struct nodes + } + + return mcids; +} + +/** + * Check if a string is whitespace-only (including empty). + * Used to filter out horizontal skip spaces in PDF content. + */ +function isWhitespaceOnly(str: string): boolean { + return str.trim().length === 0; +} + +/** + * Compute union bounding box from multiple items. + * Filters out whitespace-only text items to avoid including horizontal skips. + */ +function unionBBox(items: MarkedTextItem[]): BBox | null { + // Filter out whitespace-only items (these are often horizontal skips) + const contentItems = items.filter((item) => !isWhitespaceOnly(item.str)); + if (contentItems.length === 0) return null; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const page = contentItems[0].page; + + for (const item of contentItems) { + minX = Math.min(minX, item.x); + minY = Math.min(minY, item.y); + maxX = Math.max(maxX, item.x + item.width); + maxY = Math.max(maxY, item.y + item.height); + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + page, + }; +} + +/** + * Compute semantic bounding box for a structure node. + * Uses only direct MCIDs (non-recursive) to avoid including nested elements + * like margin content that may be children of body paragraphs. + */ +function computeStructBBox( + node: StructTreeNode, + mcidToTextItems: Map, +): BBox | null { + const mcids = collectDirectMcids(node); + const items = mcids.flatMap((id) => mcidToTextItems.get(id) ?? []); + return unionBBox(items); +} + +// ============================================================================ +// Main Predicate +// ============================================================================ + +/** + * Verify spatial positions of text in a rendered PDF using semantic structure. + * Uses pdfjs-dist to access MCIDs and structure tree. + */ +export const ensurePdfTextPositions = ( + file: string, + assertions: PdfTextPositionAssertion[], + noMatchAssertions?: PdfTextPositionAssertion[], +): Verify => { + return { + name: `Inspecting ${file} for text position assertions`, + verify: async (_output: ExecuteOutput[]) => { + const errors: string[] = []; + + // Stage 1: Parse assertions and gather search texts + const normalizedAssertions = assertions.map((a) => ({ + subject: normalizeSelector(a.subject), + relation: a.relation, + object: a.object ? normalizeSelector(a.object) : undefined, + tolerance: a.tolerance ?? DEFAULT_ALIGNMENT_TOLERANCE, + })); + + const normalizedNoMatch = noMatchAssertions?.map((a) => ({ + subject: normalizeSelector(a.subject), + relation: a.relation, + object: a.object ? normalizeSelector(a.object) : undefined, + tolerance: a.tolerance ?? DEFAULT_ALIGNMENT_TOLERANCE, + })); + + // Track search texts and their selectors (to know if Decoration type is requested) + const searchTexts = new Set(); + const textToSelectors = new Map(); + + const addSelector = (sel: TextSelector) => { + searchTexts.add(sel.text); + const existing = textToSelectors.get(sel.text) ?? []; + existing.push(sel); + textToSelectors.set(sel.text, existing); + }; + + for (const a of normalizedAssertions) { + addSelector(a.subject); + if (a.object) addSelector(a.object); + } + for (const a of normalizedNoMatch ?? []) { + addSelector(a.subject); + if (a.object) addSelector(a.object); + } + + // Helper: check if any selector for this text is a Decoration (untagged content) + const isDecoration = (text: string): boolean => { + const selectors = textToSelectors.get(text) ?? []; + return selectors.some((s) => s.type === "Decoration"); + }; + + // Stage 2: Load PDF with pdfjs-dist + // deno-lint-ignore no-explicit-any + const pdfjsLib = await import("pdfjs-dist") as any; + const buffer = await Deno.readFile(file); + const doc = await pdfjsLib.getDocument({ data: buffer }).promise; + + // Stage 3 & 4: Extract content and structure tree per page + const allTextItems: MarkedTextItem[] = []; + const mcidToTextItems = new Map(); + const mcidToStructNode = new Map(); + + for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { + const page = await doc.getPage(pageNum); + const viewport = page.getViewport({ scale: 1.0 }); + + // Get text content with marked content + const textContent = await page.getTextContent({ + includeMarkedContent: true, + }); + + const pageItems = extractMarkedTextItems( + textContent.items, + pageNum, + viewport.height, + ); + allTextItems.push(...pageItems); + + // Build MCID -> text items map + for (const item of pageItems) { + if (item.mcid) { + const existing = mcidToTextItems.get(item.mcid) ?? []; + existing.push(item); + mcidToTextItems.set(item.mcid, existing); + } + } + + // Get structure tree and build MCID -> struct node map + const structTree = await page.getStructTree(); + if (structTree) { + buildMcidStructMap(structTree, mcidToStructNode); + } + } + + // Stage 5: Find text items for each search text (must be unique, unless Decoration) + const foundTexts = new Map(); + for (const searchText of searchTexts) { + const matches = allTextItems.filter((t) => t.str.includes(searchText)); + if (matches.length === 1) { + foundTexts.set(searchText, matches[0]); + } else if (matches.length > 1) { + // Decoration types (headers, footers) naturally repeat on each page - allow first match + if (isDecoration(searchText)) { + foundTexts.set(searchText, matches[0]); + } else { + errors.push( + `Text "${searchText}" is ambiguous - found ${matches.length} matches. Use a more specific search string.`, + ); + } + } + // If matches.length === 0, we'll report "not found" later + } + + // Stage 6 & 7: Resolve selectors to structure nodes and compute bboxes + const resolvedSelectors = new Map(); + + for (const searchText of searchTexts) { + const textItem = foundTexts.get(searchText); + if (!textItem) { + errors.push(`Text not found in PDF: "${searchText}"`); + continue; + } + + let structNode: StructTreeNode | null = null; + let bbox: BBox; + + // Decoration type: use text item bounds directly (for headers, footers, page decorations) + if (isDecoration(searchText)) { + bbox = { + x: textItem.x, + y: textItem.y, + width: textItem.width, + height: textItem.height, + page: textItem.page, + }; + } else if (!textItem.mcid) { + errors.push( + `Text "${searchText}" has no MCID - PDF may not be tagged. Use type: "Decoration" for untagged page elements like headers/footers.`, + ); + continue; + } else { + structNode = mcidToStructNode.get(textItem.mcid) ?? null; + + // Same-MCID approach: compute bbox from all text items sharing this MCID + const mcidItems = mcidToTextItems.get(textItem.mcid); + if (mcidItems && mcidItems.length > 0) { + const mcidBBox = unionBBox(mcidItems); + if (mcidBBox) { + bbox = mcidBBox; + } else { + errors.push( + `Could not compute bbox for "${searchText}" - all text items in MCID are whitespace-only`, + ); + continue; + } + } else { + errors.push( + `No text items found for MCID ${textItem.mcid} containing "${searchText}"`, + ); + continue; + } + } + + resolvedSelectors.set(searchText, { + selector: { text: searchText }, + textItem, + structNode, + bbox, + }); + } + + // Validate type and id assertions + for (const a of normalizedAssertions) { + const resolved = resolvedSelectors.get(a.subject.text); + if (!resolved) continue; + + if (a.subject.type && resolved.structNode) { + if (resolved.structNode.role !== a.subject.type) { + errors.push( + `Tag type mismatch for "${a.subject.text}": expected ${a.subject.type}, got ${resolved.structNode.role}`, + ); + } + } + + if (a.object) { + const resolvedObj = resolvedSelectors.get(a.object.text); + if (!resolvedObj) continue; + + if (a.object.type && resolvedObj.structNode) { + if (resolvedObj.structNode.role !== a.object.type) { + errors.push( + `Tag type mismatch for "${a.object.text}": expected ${a.object.type}, got ${resolvedObj.structNode.role}`, + ); + } + } + } + } + + // Stage 8: Evaluate position assertions + for (const a of normalizedAssertions) { + // Tag-only assertions (no relation/object) + if (!a.relation || !a.object) { + continue; // Already validated in stage 6 + } + + const subjectResolved = resolvedSelectors.get(a.subject.text); + const objectResolved = resolvedSelectors.get(a.object.text); + + if (!subjectResolved || !objectResolved) { + continue; // Error already recorded + } + + const relationFn = pdfPositionRelations[a.relation]; + if (!relationFn) { + errors.push( + `Unknown relation "${a.relation}". Valid relations: ${Object.keys(pdfPositionRelations).join(", ")}`, + ); + continue; + } + + // Check same page + if (subjectResolved.bbox.page !== objectResolved.bbox.page) { + errors.push( + `Cannot compare positions: "${a.subject.text}" is on page ${subjectResolved.bbox.page}, ` + + `"${a.object.text}" is on page ${objectResolved.bbox.page}`, + ); + continue; + } + + // Evaluate relation + if (!relationFn(subjectResolved.bbox, objectResolved.bbox, a.tolerance)) { + errors.push( + `Position assertion failed: "${a.subject.text}" is NOT ${a.relation} "${a.object.text}". ` + + `Subject bbox: (${subjectResolved.bbox.x.toFixed(1)}, ${subjectResolved.bbox.y.toFixed(1)}, ` + + `w=${subjectResolved.bbox.width.toFixed(1)}, h=${subjectResolved.bbox.height.toFixed(1)}) page ${subjectResolved.bbox.page}, ` + + `Object bbox: (${objectResolved.bbox.x.toFixed(1)}, ${objectResolved.bbox.y.toFixed(1)}, ` + + `w=${objectResolved.bbox.width.toFixed(1)}, h=${objectResolved.bbox.height.toFixed(1)}) page ${objectResolved.bbox.page}`, + ); + } + } + + // Evaluate negative assertions + for (const a of normalizedNoMatch ?? []) { + if (!a.relation || !a.object) continue; + + const subjectResolved = resolvedSelectors.get(a.subject.text); + const objectResolved = resolvedSelectors.get(a.object.text); + + if (!subjectResolved || !objectResolved) { + continue; // Assertion trivially doesn't hold + } + + if (subjectResolved.bbox.page !== objectResolved.bbox.page) { + continue; // Assertion trivially doesn't hold + } + + const relationFn = pdfPositionRelations[a.relation]; + if (!relationFn) { + errors.push( + `Unknown relation "${a.relation}" in negative assertion`, + ); + continue; + } + + if (relationFn(subjectResolved.bbox, objectResolved.bbox, a.tolerance)) { + errors.push( + `Negative assertion failed: "${a.subject.text}" IS ${a.relation} "${a.object.text}" (expected NOT to be). ` + + `Subject bbox: (${subjectResolved.bbox.x.toFixed(1)}, ${subjectResolved.bbox.y.toFixed(1)}) page ${subjectResolved.bbox.page}, ` + + `Object bbox: (${objectResolved.bbox.x.toFixed(1)}, ${objectResolved.bbox.y.toFixed(1)}) page ${objectResolved.bbox.page}`, + ); + } + } + + // Stage 9: Aggregate errors + if (errors.length > 0) { + assert( + false, + `PDF position assertions failed in ${file}:\n${errors.map((e, i) => ` ${i + 1}. ${e}`).join("\n")}`, + ); + } + }, + }; +}; diff --git a/tests/verify.ts b/tests/verify.ts index a38dbbd01d5..06e717b8f2c 100644 --- a/tests/verify.ts +++ b/tests/verify.ts @@ -1143,3 +1143,9 @@ const asRegexp = (m: string | RegExp) => { return m; } }; + +// Re-export ensurePdfTextPositions from dedicated module +export { ensurePdfTextPositions } from "./verify-pdf-text-position.ts"; + +// Re-export ensurePdfMetadata from dedicated module +export { ensurePdfMetadata } from "./verify-pdf-metadata.ts";