diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5d7f27..1775a45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: # Every target except `no_write_on_open`, which gets the step below to itself. - name: Test working-directory: src-tauri - run: cargo test --lib --bins --test fs --test fts5 --test watch --test watch_payload + run: cargo test --lib --bins --test fs --test fts5 --test watch --test watch_payload --test pdf --test grammar --test export_writes_only_the_pdf # The suite that checks the promise the whole project rests on, that the editor writes # nothing the user did not edit. It runs against a real git repository rather than against a diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1115bb..6e26b35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,13 @@ jobs: IFS=. read -r MAJOR MINOR PATCH <<< "$CURRENT" VERSION="$MAJOR.$MINOR.$((PATCH + 1))" fi + # This string becomes a git tag, a TOML value and a JSON value, and it is typed into a + # box by hand. Anything that is not three numbers is a release that goes wrong somewhere + # further down, where it is much harder to read. + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::'$VERSION' is not a version. Give three numbers separated by dots, such as 0.2.0." + exit 1 + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" echo "Releasing v$VERSION" @@ -43,7 +50,29 @@ jobs: tmp=$(mktemp) jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > "$tmp" && mv "$tmp" src-tauri/tauri.conf.json jq --arg v "$VERSION" '.version = $v' package.json > "$tmp" && mv "$tmp" package.json - sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" src-tauri/Cargo.toml + # The crate's own version, and only that one. Anchored to the [package] section rather + # than to the first `version =` line in the file, because a dependency written in the + # long form puts `version = "0.4"` on a line of its own and the first such line is not + # necessarily the crate's. Bumping the wrong one is a release that builds and ships the + # version before it. + awk -v v="$VERSION" ' + /^\[/ { section = $0 } + section == "[package]" && !done && /^version[[:space:]]*=/ { + print "version = \"" v "\"" + done = 1 + next + } + { print } + END { if (!done) exit 1 } + ' src-tauri/Cargo.toml > "$tmp" || { + echo "::error::No version key under [package] in src-tauri/Cargo.toml. Nothing was bumped." + exit 1 + } + mv "$tmp" src-tauri/Cargo.toml + if ! grep -q "^version = \"$VERSION\"\$" src-tauri/Cargo.toml; then + echo "::error::src-tauri/Cargo.toml does not carry version $VERSION after the bump." + exit 1 + fi - name: Commit and tag env: @@ -111,6 +140,62 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile + # Apple codesigning and notarization. Put into the environment here rather than passed + # straight to the build step, because the bundler reads all of these with `var_os` and an + # empty string counts as set: a step that always passed `${{ secrets.APPLE_CERTIFICATE }}` + # would make a repository without a certificate fail on an empty .p12 rather than fall back + # to an ad hoc signature. What is not written below is simply not in the build's environment. + # + # So a repository with none of these secrets still builds and still publishes. What it gets + # is an ad hoc signed bundle, which is fine to install by hand and is not fine as an update: + # macOS will not let one replace a Developer ID signed copy. docs/release.md has the whole of + # that, including why self-update cannot work until the certificate exists. + # + # Half-configured is the one case that fails rather than warning. A repository that has a + # certificate but no notarization credentials produces a signed bundle Gatekeeper still + # refuses on any machine that has not seen it before, and doing that quietly is worse than + # not building. + - name: Prepare Apple signing + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + keep() { + delim="EOF_$(openssl rand -hex 12)" + { + printf '%s<<%s\n' "$1" "$delim" + printf '%s\n' "$2" + printf '%s\n' "$delim" + } >> "$GITHUB_ENV" + } + + if [ -z "$APPLE_CERTIFICATE" ] && [ -z "$APPLE_SIGNING_IDENTITY" ]; then + echo "::warning::No Developer ID certificate is configured. This build will be ad hoc signed, and installed copies will not be able to update themselves. See docs/release.md." + exit 0 + fi + + if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_CERTIFICATE_PASSWORD" ] || [ -z "$APPLE_SIGNING_IDENTITY" ]; then + echo "::error::Apple signing is half configured. APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD and APPLE_SIGNING_IDENTITY are set together or not at all." + exit 1 + fi + + if [ -z "$APPLE_ID" ] || [ -z "$APPLE_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then + echo "::error::A Developer ID signed build has to be notarized or Gatekeeper refuses it on any machine that has not seen it before. Set APPLE_ID, APPLE_PASSWORD and APPLE_TEAM_ID." + exit 1 + fi + + keep APPLE_CERTIFICATE "$APPLE_CERTIFICATE" + keep APPLE_CERTIFICATE_PASSWORD "$APPLE_CERTIFICATE_PASSWORD" + keep APPLE_SIGNING_IDENTITY "$APPLE_SIGNING_IDENTITY" + keep APPLE_ID "$APPLE_ID" + keep APPLE_PASSWORD "$APPLE_PASSWORD" + keep APPLE_TEAM_ID "$APPLE_TEAM_ID" + echo "Signing as a Developer ID application and notarizing." + - name: Build and upload uses: tauri-apps/tauri-action@v0 env: @@ -134,6 +219,17 @@ jobs: gh release download "$TAG" --repo "$REPO" --pattern latest.json --output latest.json --clobber echo "Platforms in latest.json:" jq '.platforms | keys' latest.json + + # The manifest has to describe this release and not the one before it. tauri-action writes + # the version out of the manifests the build job checked out, so a mismatch here means the + # tag and the bump have come apart somewhere, and publishing it would tell every installed + # copy that the version it is already running is the newest one. + MANIFEST_VERSION=$(jq -r '.version // ""' latest.json) + if [ "${MANIFEST_VERSION#v}" != "${TAG#v}" ]; then + echo "::error::latest.json says version '$MANIFEST_VERSION' but the tag is '$TAG'. Refusing to publish a manifest that does not describe this release." + exit 1 + fi + # A universal build emits one .app.tar.gz, but tauri-action writes it into latest.json # under both darwin-aarch64 and darwin-x86_64, pointing them at the same file and the same # signature. It has to: an installed copy asks the manifest for the architecture it is @@ -141,10 +237,22 @@ jobs: # darwin-universal would offer nobody an update. Those two keys are the whole manifest for # this app, and a release that is missing either one is a release half the users cannot # take. + # + # The signature is checked alongside the url because an unsigned entry is not a smaller + # problem than a missing one. The updater refuses a download whose signature does not + # verify against the public key baked into the app, so an entry with an empty signature is + # an update every installed copy will offer, download and then reject. for key in darwin-aarch64 darwin-x86_64; do - if ! jq -e ".platforms[\"$key\"].url" latest.json > /dev/null; then + url=$(jq -r ".platforms[\"$key\"].url // \"\"" latest.json) + signature=$(jq -r ".platforms[\"$key\"].signature // \"\"" latest.json) + if [ -z "$url" ]; then echo "::error::latest.json is missing platform '$key': refusing to publish a partial update manifest. Re-run the release." exit 1 fi + if [ -z "$signature" ]; then + echo "::error::latest.json has no signature for platform '$key'. An installed copy would download the update and refuse it. Check that TAURI_SIGNING_PRIVATE_KEY is set." + exit 1 + fi done + gh release edit "$TAG" --repo "$REPO" --draft=false --latest diff --git a/docs/release.md b/docs/release.md index e7d61c1..dbaad75 100644 --- a/docs/release.md +++ b/docs/release.md @@ -23,12 +23,16 @@ and `src-tauri/Cargo.toml` together, commits that to main, tags it, and builds t whatever main happens to be by then. It builds one universal macOS bundle and publishes nothing until it has landed. The last job -downloads `latest.json` and refuses to take the release out of draft unless `darwin-aarch64` and -`darwin-x86_64` are both present in it. One universal build writes both of those keys, pointing -them at the same archive and the same signature, because an installed copy asks the manifest for -the architecture it is running on and never for a universal one. A half-populated manifest is worse -than no release at all: the updater would offer an update to the platforms that made it and error -on the ones that did not. +downloads `latest.json` and refuses to take the release out of draft unless three things hold: the +version in the manifest is the version in the tag, `darwin-aarch64` and `darwin-x86_64` are both +present, and each of them has a signature as well as a url. One universal build writes both of +those keys, pointing them at the same archive and the same signature, because an installed copy +asks the manifest for the architecture it is running on and never for a universal one. A +half-populated manifest is worse than no release at all: the updater would offer an update to the +platforms that made it and error on the ones that did not. A manifest carrying the version before +this one is worse again, because it tells everybody the copy they are already running is the newest +there is, and an entry with an empty signature is an update every installed copy downloads and then +refuses. Linux is not built. It was until recently, because this pipeline was copied from margin-calendar, which ships on Linux. This app does not, so the row and the manifest key it required are gone and @@ -38,17 +42,54 @@ Windows is not built. Nothing in `tauri.conf.json` targets it and the app has ne ## What the build needs -Two repository secrets: `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, -which sign the updater artifacts so an installed copy can tell a real update from anything else -offered at the same URL. The public half lives in `src-tauri/tauri.release.conf.json`, baked into -every build, so the private half can never be rotated without stranding everyone who has not -updated yet; back up the key and its password somewhere that is not the machine that generated -them. That file currently carries the placeholder `REPLACE_WITH_TAURI_SIGNER_PUBKEY`; generating -the real keypair and committing its public half in is a one-time step that has to happen before the -first signed release can go out. +Two repository secrets sign the updater artifacts, so that an installed copy can tell a real update +from anything else offered at the same URL: `TAURI_SIGNING_PRIVATE_KEY` and +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. The public half lives in +`src-tauri/tauri.release.conf.json`, baked into every build, so the private half can never be +rotated without stranding everyone who has not updated yet; back up the key and its password +somewhere that is not the machine that generated them. -Beyond the signing key there is nothing to provision. Margin Docs talks to no external API and -holds no OAuth client, unlike margin-calendar, so there are no other repository secrets and nothing +That file currently carries the placeholder `REPLACE_WITH_TAURI_SIGNER_PUBKEY`, and the keypair +behind it does not exist yet. Making it is a one-time step, run once by whoever owns the repository +and never again: + +``` +pnpm tauri signer generate -w ~/.tauri/margin-docs.key +``` + +It asks for a password, writes the private key to that path and the public key beside it as +`margin-docs.key.pub`, and prints both. The public one replaces the placeholder in +`tauri.release.conf.json` and is committed. The private one is the contents of +`~/.tauri/margin-docs.key`, pasted into `TAURI_SIGNING_PRIVATE_KEY`, with its password in +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. Neither the key file nor the password goes into the +repository. + +Six more secrets codesign and notarize the bundle, and unlike the two above they are optional as +far as the workflow is concerned: with none of them set the build still runs, still publishes, and +produces an ad hoc signed app. `APPLE_CERTIFICATE` is the Developer ID Application certificate +exported from Keychain Access as a `.p12` and base64 encoded (`base64 -i certificate.p12`), with +the password it was exported under in `APPLE_CERTIFICATE_PASSWORD`. `APPLE_SIGNING_IDENTITY` is the +certificate's full name, which looks like `Developer ID Application: Your Name (TEAMID)`. The other +three are for notarization: `APPLE_ID` is the Apple account's email address, `APPLE_PASSWORD` is an +app-specific password made at appleid.apple.com rather than the account password itself, and +`APPLE_TEAM_ID` is the ten character team identifier. + +Those six are all or nothing. A repository with a certificate but no notarization credentials fails +the build rather than warning, because a signed bundle that has not been notarized is one Gatekeeper +refuses on any machine that has not seen it before, and finding that out from a user is worse than +finding it out from a red run. + +The bundle asks for the hardened runtime, which notarization requires, and for no entitlements at +all. That is worth a sentence because it nearly went the other way. Deleting a document goes to the +Trash through the `trash` crate, whose macOS default is to ask Finder over an Apple event, and the +hardened runtime blocks an Apple event unless the bundle carries +`com.apple.security.automation.apple-events` and the user agrees to a permission prompt about +controlling Finder. `src-tauri/src/fs.rs` asks for `trashItemAtURL:` instead, which needs none of +that. The cost is Put Back, which the Finder method leaves on the file and this one does not; the +file is still in the Trash and can still be dragged out of it. + +Beyond signing there is nothing to provision. Margin Docs talks to no external API and holds no +OAuth client, unlike margin-calendar, so there are no other repository secrets and nothing equivalent to a `google-credentials.json` to embed at build time. ## Updates @@ -56,4 +97,21 @@ equivalent to a `google-credentials.json` to embed at build time. Installed copies check `https://github.com/priyanshujain/margin-docs/releases/latest/download/latest.json` and update themselves from it. `--latest` on the publish step is what moves that pointer, so a release that -fails the manifest check stays a draft and no one is offered a broken update. +fails the manifest check stays a draft and no one is offered a broken update. The app checks that +URL once a day in the background and on the Check for Updates menu item, and either way what +happens next is a dialog rather than an install: the version, the release notes, the download with +a progress bar, and Later as a real answer. + +Self-update does not work yet, and it will not until there is an Apple Developer ID certificate. +This is not a gap in the workflow, which is wired for one, or in the app, which is wired for the +whole flow. It is macOS: an update replaces the installed bundle with the downloaded one, and the +system will not let an ad hoc signed bundle take the place of a signed one, nor run a replacement +whose signature does not match what was there before. Every build this repository can currently +produce is ad hoc signed, so every self-update attempt ends in a bundle the system refuses to +launch. Until the certificate exists, updates are something to install by hand, and the honest +version of the feature is that the app will find the update, show it, download it, and fail on the +last step. + +There is a second thing gated behind the same certificate. The build is not notarized either, so a +copy downloaded from the releases page is quarantined by the browser and refused on first launch +with the message about an unidentified developer. Both problems have the one fix. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8a20dbc..6098601 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -32,6 +44,24 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ammonia" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d" +dependencies = [ + "cssparser 0.37.0", + "html5ever 0.39.0", + "maplit", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -41,12 +71,80 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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 = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -56,6 +154,27 @@ dependencies = [ "derive_arbitrary", ] +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading 0.8.9", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -216,12 +335,24 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atomic_float" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "az" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" + [[package]] name = "base64" version = "0.21.7" @@ -234,6 +365,39 @@ 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 0.27.2", + "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 = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -264,6 +428,23 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blanket" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56791e4bd64c99fc361e01008f45c984baa93f12a0957d1b3c51dd2c6baab453" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -341,11 +522,333 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "burn" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0291ea5c68786545e239a02f63331cfe39da7485164ae05197d5be6f148d0557" +dependencies = [ + "burn-autodiff", + "burn-candle", + "burn-core", + "burn-cuda", + "burn-ndarray", + "burn-nn", + "burn-optim", + "burn-rocm", + "burn-router", + "burn-store", + "burn-wgpu", +] + +[[package]] +name = "burn-autodiff" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917423a74bf4d39f17a6799089869648e3d2b6ac89d93901aab4aeb9a7f82138" +dependencies = [ + "burn-common", + "burn-tensor", + "derive-new", + "hashbrown 0.15.5", + "log", + "num-traits", + "portable-atomic", + "spin 0.10.1", +] + +[[package]] +name = "burn-candle" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2891811d41ae30b5f1f660e7615b757b2cb4128af5e311b213656de3875e4acb" +dependencies = [ + "burn-common", + "burn-tensor", + "candle-core", + "derive-new", + "half", +] + +[[package]] +name = "burn-common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb445304e4f91f8633d23c9a5258cd93639d13ce2ee47d4821fd519b683bf02" +dependencies = [ + "cubecl-common", + "rayon", + "serde", +] + +[[package]] +name = "burn-core" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c93e754864080a8c27b9a47e3b6f7d79013cf82c9ce00ed57c9ba51a3e34c5" +dependencies = [ + "ahash", + "bincode 2.0.1", + "burn-common", + "burn-derive", + "burn-tensor", + "data-encoding", + "derive-new", + "flate2", + "half", + "hashbrown 0.15.5", + "log", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rand 0.9.5", + "regex", + "rmp-serde", + "serde", + "serde_json", + "spin 0.10.1", + "uuid", +] + +[[package]] +name = "burn-cubecl" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd16308b7b0291c77f2d7acf428bc8254ec3db88a430a26cf3d3b0b63ae2d46" +dependencies = [ + "burn-common", + "burn-cubecl-fusion", + "burn-fusion", + "burn-ir", + "burn-tensor", + "bytemuck", + "cubecl", + "cubecl-quant", + "derive-new", + "futures-lite", + "half", + "hashbrown 0.15.5", + "log", + "num-traits", + "rand 0.9.5", + "serde", + "spin 0.10.1", + "text_placeholder", +] + +[[package]] +name = "burn-cubecl-fusion" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc21cf88201dfbf242cadb638a0cc924010727fc37d6a719f7e10548b339c63a" +dependencies = [ + "burn-common", + "burn-fusion", + "burn-ir", + "burn-tensor", + "cubecl", + "cubecl-quant", + "derive-new", + "half", + "serde", +] + +[[package]] +name = "burn-cuda" +version = "0.19.1" + +[[package]] +name = "burn-derive" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcf49261de086b8206de6c8962d2adf23feb476119a18e384f5b2c9af07c0cf" +dependencies = [ + "derive-new", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "burn-fusion" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662bf2679c04be34a0c3f1b11f77f6ff49456af1620d1eca311bc2562bbb56c9" +dependencies = [ + "burn-common", + "burn-ir", + "burn-tensor", + "derive-new", + "half", + "hashbrown 0.15.5", + "log", + "serde", + "spin 0.10.1", +] + +[[package]] +name = "burn-ir" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9161239d5691c4ab6f470f2c65aaec5c0a7c1f0b0da390700bcd59f5a77d1d7b" +dependencies = [ + "burn-tensor", + "hashbrown 0.15.5", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "burn-ndarray" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b78bcf4a3508043342f918e796dc79108b5f3252398403eb73952847e7683374" +dependencies = [ + "atomic_float", + "burn-autodiff", + "burn-common", + "burn-ir", + "burn-tensor", + "const-random", + "derive-new", + "libm", + "macerator", + "matrixmultiply", + "ndarray", + "num-traits", + "paste", + "portable-atomic-util", + "rand 0.9.5", + "spin 0.10.1", +] + +[[package]] +name = "burn-nn" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7829c87c4dd6c7929b50fd981e7e8d1b77414323da30ce2067a3e8b7ea422b" +dependencies = [ + "burn-core", + "num-traits", +] + +[[package]] +name = "burn-optim" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31758c02e50247f12457fca1905ed8684ac1b1c5292e10cbbfffb9fa0048d4bd" +dependencies = [ + "burn-core", + "derive-new", + "hashbrown 0.15.5", + "log", + "num-traits", + "serde", +] + +[[package]] +name = "burn-rocm" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1ceb87b6e7349b42d7995477c9a69d0e6c458c64eafa10af3b8b9070f260aa" +dependencies = [ + "burn-cubecl", + "burn-tensor", + "bytemuck", + "cubecl", + "derive-new", + "half", + "log", +] + +[[package]] +name = "burn-router" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f40403c500b5df380bee47aa0f23032350bdfde5402812d6fcec4d6ff6fbad" +dependencies = [ + "burn-common", + "burn-ir", + "burn-tensor", + "hashbrown 0.15.5", + "log", + "spin 0.10.1", +] + +[[package]] +name = "burn-store" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2a163486242fcb0c6e2cb89c5a803ab8588673652bb46ecd7af6378d06152f" +dependencies = [ + "burn-core", + "burn-nn", + "burn-tensor", + "byteorder", + "half", + "hashbrown 0.15.5", + "memmap2", + "regex", + "safetensors 0.6.2", +] + +[[package]] +name = "burn-tensor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8861f7c21d3b07a2b19d028f6eb8903990949708b2ec825559b5200786877c" +dependencies = [ + "burn-common", + "bytemuck", + "colored", + "cubecl", + "cubecl-quant", + "derive-new", + "half", + "hashbrown 0.15.5", + "num-traits", + "rand 0.9.5", + "rand_distr", + "serde", + "serde_bytes", +] + +[[package]] +name = "burn-wgpu" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17aeaa2eadaa4831a64672b99f62ffcdf4874fe4757080633d8a6c4452e2b38" +dependencies = [ + "burn-cubecl", + "burn-tensor", + "cubecl", +] + +[[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.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "byteorder" @@ -353,6 +856,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -362,6 +871,40 @@ dependencies = [ "serde", ] +[[package]] +name = "cached" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b6f5d101f0f6322c8646a45b7c581a673e476329040d97565815c2461dd0c4" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.16.1", + "once_cell", + "parking_lot", + "thiserror 2.0.20", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ebcf9c75f17a17d55d11afc98e46167d4790a263f428891b8705ab2f793eca3" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + [[package]] name = "cairo-rs" version = "0.18.5" @@ -396,6 +939,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "candle-core" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c15b675b80d994b2eadb20a4bbe434eabeb454eac3ee5e2b4cf6f147ee9be091" +dependencies = [ + "byteorder", + "float8 0.6.1", + "gemm", + "half", + "libm", + "memmap2", + "num-traits", + "num_cpus", + "rand 0.9.5", + "rand_distr", + "rayon", + "safetensors 0.7.0", + "thiserror 2.0.20", + "yoke 0.8.3", + "zip 7.2.0", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -436,6 +1002,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -472,6 +1040,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d1275808335c5583ea45a4141d631dafcb36b79fa8a5e10b00e356b6af3e08a" + [[package]] name = "chrono" version = "0.4.45" @@ -479,11 +1071,137 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[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 0.38.4", + "serde", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -494,6 +1212,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "comemo" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c963350b2b08aa4b725d7802593245380ab53dacfedcaa971385fc33306c0d4" +dependencies = [ + "comemo-macros", + "parking_lot", + "rustc-hash 2.1.3", + "siphasher", + "slab", +] + +[[package]] +name = "comemo-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c400139ba1389ef9e20ad2d87cda68b437a66483aa0da616bdf2cea7413853" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -503,6 +1245,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.2" @@ -553,6 +1324,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -571,6 +1351,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -605,6 +1391,12 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -628,6 +1420,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "dtoa-short", + "itoa", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -638,6 +1441,27 @@ dependencies = [ "syn 2.0.119", ] +[[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 = "ctor" version = "0.8.0" @@ -654,14 +1478,431 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "cubecl" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b7c74ecaca9356c9ae79d0ebf1db04f02bd98be09eea61f51d73373dffe758" +dependencies = [ + "cubecl-convolution", + "cubecl-core", + "cubecl-cpu", + "cubecl-cuda", + "cubecl-hip", + "cubecl-matmul", + "cubecl-random", + "cubecl-reduce", + "cubecl-runtime", + "cubecl-std", + "cubecl-wgpu", + "half", +] + +[[package]] +name = "cubecl-common" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4556981155bffc057a8effcd4549b52b51df3e9edec43af6ccae2dd03fc8fbff" +dependencies = [ + "bytemuck", + "cfg-if", + "cfg_aliases", + "derive-new", + "derive_more 2.1.1", + "dirs", + "embassy-futures", + "embassy-time", + "float4", + "float8 0.4.2", + "futures-lite", + "half", + "hashbrown 0.15.5", + "log", + "num-traits", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "rand 0.9.5", + "sanitize-filename", + "serde", + "serde_bytes", + "serde_json", + "spin 0.10.1", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "cubecl-convolution" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c624ec400b7203673bf2db86d7ff30d1384839d497d2dd029c19b1b7371e0d" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-matmul", + "cubecl-random", + "cubecl-reduce", + "cubecl-runtime", + "cubecl-std", + "half", + "serde", +] + +[[package]] +name = "cubecl-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffc10af538ee74535cda260e581f5a177c243803dd30b698934a515f0114b55" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cubecl-common", + "cubecl-ir", + "cubecl-macros", + "cubecl-runtime", + "derive-new", + "derive_more 2.1.1", + "enumset", + "half", + "hashbrown 0.15.5", + "log", + "num-traits", + "paste", + "serde", + "serde_json", + "variadics_please", +] + +[[package]] +name = "cubecl-cpp" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630e4d10cdd3af268ac753914ca79b48f01d1e36c5b5039970a817acc925fea" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-opt", + "cubecl-runtime", + "derive-new", + "half", + "itertools", + "log", +] + +[[package]] +name = "cubecl-cpu" +version = "0.8.1" + +[[package]] +name = "cubecl-cuda" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67215fcd552a9e8bc68494a71cf2979f2e2bbcbda60f0695f56f86705b89ed5f" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-runtime", + "cudarc", + "derive-new", + "half", + "log", + "serde", +] + +[[package]] +name = "cubecl-hip" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5e2e6a257f702fb2eb6f24e640e228a94695e4a4c73a4c549578cbb02ad4ec5" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-cpp", + "cubecl-hip-sys", + "cubecl-quant", + "cubecl-runtime", + "derive-new", + "half", + "log", + "paste", + "serde", +] + +[[package]] +name = "cubecl-hip-sys" +version = "7.14.6085000" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760c605ca1b54d12ba9209d2a47992082ff66346fbd96d830af22ac43adfeebc" +dependencies = [ + "libc", + "regex", +] + +[[package]] +name = "cubecl-ir" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf5d3aa7857e6aee1622aef128d6ad8d9289ed57362b4e65d10cc182aafc585f" +dependencies = [ + "cubecl-common", + "cubecl-macros-internal", + "derive-new", + "derive_more 2.1.1", + "float-ord", + "fnv", + "half", + "hashbrown 0.15.5", + "num-traits", + "portable-atomic", + "serde", + "variadics_please", +] + +[[package]] +name = "cubecl-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5200fb619be424749901e3c6e8e66ae71146c8f83636a74f171bd980cba379d7" +dependencies = [ + "cubecl-common", + "darling 0.21.3", + "derive-new", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cubecl-macros-internal" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a1b673f303396fba18df83368aa4eced474584f1bca34852dccc42bd4ff050c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cubecl-matmul" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cf0a00609a249d5357c27cafea477f35218579db2ab00582d8d5800be4a5a3" +dependencies = [ + "bytemuck", + "cubecl-common", + "cubecl-core", + "cubecl-random", + "cubecl-reduce", + "cubecl-runtime", + "cubecl-std", + "half", + "serde", +] + +[[package]] +name = "cubecl-opt" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870ca4b52f9eebd358c9b360b89cdc9f82bde05682db63f0e90c666b3c85a04d" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-ir", + "float-ord", + "log", + "num", + "petgraph", + "smallvec", + "stable-vec", + "type-map", +] + +[[package]] +name = "cubecl-quant" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be3e1202c219078d85dbad7f30d1195fe4f9d42cbfad2c94ab0ea1a6d9f01f6" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "cubecl-std", + "half", + "serde", +] + +[[package]] +name = "cubecl-random" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a293a05caa68663675823bab66205bca094a21a2c0f6686ad9f20b392516179" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "cubecl-std", + "half", + "num-traits", + "rand 0.9.5", + "serde", +] + +[[package]] +name = "cubecl-reduce" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53306ace81f6262f7ae794370f47e6b5019842b27e8800240e5b039386b3ac3a" +dependencies = [ + "cubecl-core", + "cubecl-runtime", + "cubecl-std", + "half", + "num-traits", + "serde", +] + +[[package]] +name = "cubecl-runtime" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b823bb5899a6fa8809bf7aa36f93f72ced6de58ab9d6edea2c730b235eeda3" +dependencies = [ + "async-channel", + "bytemuck", + "cfg-if", + "cfg_aliases", + "cubecl-common", + "cubecl-ir", + "derive-new", + "dirs", + "enumset", + "foldhash 0.1.5", + "hashbrown 0.15.5", + "log", + "md5", + "serde", + "serde_json", + "spin 0.10.1", + "thiserror 2.0.20", + "toml 0.9.12+spec-1.1.0", + "variadics_please", + "wasm-bindgen-futures", +] + +[[package]] +name = "cubecl-std" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24536998f9fff84f9a1dd2a90f981d5aa4d15eb35cddec5021c4fcf977d2e75e" +dependencies = [ + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "foldhash 0.1.5", + "half", + "paste", + "serde", + "spin 0.10.1", + "variadics_please", +] + +[[package]] +name = "cubecl-wgpu" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59a7d737259a784247595e2f0cc5a97d3e50f45cdaefbd4cc7d7fd2126f7a58" +dependencies = [ + "async-channel", + "bytemuck", + "cfg-if", + "cfg_aliases", + "cubecl-common", + "cubecl-core", + "cubecl-runtime", + "derive-new", + "derive_more 2.1.1", + "half", + "hashbrown 0.15.5", + "log", + "sanitize-filename", + "wgpu", +] + +[[package]] +name = "cudarc" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf99ab37ee7072d64d906aa2dada9a3422f1d975cdf8c8055a573bc84897ed8" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -677,17 +1918,51 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.12" @@ -739,6 +2014,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -750,13 +2036,34 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl 1.0.0", +] + [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl", + "derive_more-impl 2.1.1", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -765,10 +2072,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -848,6 +2157,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -855,9 +2173,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", - "cssparser", - "foldhash", - "html5ever", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", "precomputed-hash", "selectors", "tendril", @@ -914,6 +2232,68 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + +[[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.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-time" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f820157f198ada183ad62e0a66f554c610cdcd1a9f27d4b316358103ced7a1f8" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-util", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" +dependencies = [ + "document-features", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -934,12 +2314,81 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[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 = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -961,6 +2410,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumset" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -988,6 +2458,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1020,6 +2499,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[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 = "fastrand" version = "2.5.0" @@ -1035,6 +2525,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fid-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6956a1e60e2d1412b44b4169d44a03dae518f8583d3e10090c912c105e48447" +dependencies = [ + "rayon", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1070,6 +2569,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -1078,6 +2583,55 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float4" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5939bac0ef2ad7c83a53e4fb889c1d81f007b07061d648cd271071984d86f257" + +[[package]] +name = "float8" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4203231de188ebbdfb85c11f3c20ca2b063945710de04e7b59268731e728b462" +dependencies = [ + "half", +] + +[[package]] +name = "float8" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5" +dependencies = [ + "half", + "num-traits", + "rand 0.9.5", + "rand_distr", ] [[package]] @@ -1086,12 +2640,59 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[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.5.0" @@ -1137,6 +2738,12 @@ dependencies = [ "libc", ] +[[package]] +name = "fst" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" + [[package]] name = "futures-channel" version = "0.3.34" @@ -1320,6 +2927,125 @@ dependencies = [ "x11", ] +[[package]] +name = "gemm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack", + "gemm-c32", + "gemm-c64", + "gemm-common", + "gemm-f16", + "gemm-f32", + "gemm-f64", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +dependencies = [ + "dyn-stack", + "gemm-common", + "gemm-f32", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1330,6 +3056,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1364,6 +3099,26 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1396,6 +3151,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glib" version = "0.18.5" @@ -1443,6 +3209,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glidesort" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" + [[package]] name = "glob" version = "0.3.4" @@ -1462,6 +3234,27 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1473,6 +3266,57 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.56.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1525,19 +3369,137 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "rand 0.9.5", + "rand_distr", + "serde", + "zerocopy", +] + +[[package]] +name = "harper-brill" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9172d4243fc4f7711c974132bb0da6e5963720732bc17b7ef06de344b92edf9c" +dependencies = [ + "harper-pos-utils", + "serde_json", +] + +[[package]] +name = "harper-core" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acab57352d953e0ab227d4a0a7a8f990e28053f47b8d633711677a83180f51fb" +dependencies = [ + "ammonia", + "bitflags 2.13.1", + "blanket", + "cached", + "foldhash 0.2.0", + "fst", + "harper-brill", + "harper-thesaurus", + "hashbrown 0.16.1", + "is-macro", + "itertools", + "levenshtein_automata", + "lru", + "ordered-float 5.5.0", + "paste", + "pulldown-cmark", + "regex", + "serde", + "serde_json", + "smallvec", + "strum 0.28.0", + "strum_macros 0.28.0", + "thiserror 2.0.20", + "trie-rs", + "unicode-blocks", + "unicode-script", + "unicode-width", + "zip 8.6.0", +] + +[[package]] +name = "harper-pos-utils" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f5fe2cd5ca70dda252e74c4c765662b1c757b7c312576c9e8e8b3c73828c91" +dependencies = [ + "burn", + "burn-ndarray", + "hashbrown 0.16.1", + "is-macro", + "lru", + "rs-conllu", + "serde", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", +] + +[[package]] +name = "harper-thesaurus" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "492d0bbdf79449188cfc73fc7068ae0dd95f01a0755059682024cd6d6a6713ea" +dependencies = [ + "hashbrown 0.16.1", + "indexmap 2.14.0", + "itertools", + "ruzstd", + "zstd", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", + "serde", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -1546,7 +3508,9 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1558,6 +3522,109 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "hayagriva" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb69425736f184173b3ca6e27fcba440a61492a790c786b1c6af7e06a03e575" +dependencies = [ + "biblatex", + "ciborium", + "citationberg", + "indexmap 2.14.0", + "paste", + "roman-numerals-rs", + "serde", + "serde_yaml", + "thiserror 2.0.20", + "unic-langid", + "unicode-segmentation", + "unscanny", + "url", +] + +[[package]] +name = "hayro" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048488ba88552bb0fb2a7e4001c64d5bed65d1a92167186a1bb9151571f32e60" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo 0.12.0", +] + +[[package]] +name = "hayro-font" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10e7e97ce840a6a70e7901e240ec65ba61106b66b37a4a1b899a2ce484248463" +dependencies = [ + "log", + "phf", +] + +[[package]] +name = "hayro-interpret" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56204c972d08e844f3db13b1e14be769f846e576699b46d4f4637cc4f8f70102" +dependencies = [ + "bitflags 2.13.1", + "hayro-font", + "hayro-syntax", + "kurbo 0.12.0", + "log", + "moxcms 0.7.11", + "phf", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.37.0", + "smallvec", + "yoke 0.8.3", +] + +[[package]] +name = "hayro-svg" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c673304cec6e0dfd3b4f71fccecd45646899aa70279b62d3f933842abc4ac5" +dependencies = [ + "base64 0.22.1", + "hayro-interpret", + "image", + "kurbo 0.12.0", + "siphasher", + "xmlwriter", +] + +[[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 2.1.3", + "smallvec", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "hayro-write" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc05d8b4bc878b9aee48d980ecb25ed08f1dd9fad6da5ab4d9b7c56ec03a0cf6" +dependencies = [ + "flate2", + "hayro-syntax", + "log", + "pdf-writer", +] + [[package]] name = "heck" version = "0.4.1" @@ -1582,6 +3649,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "html5ever" version = "0.38.0" @@ -1589,7 +3662,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever", + "markup5ever 0.38.0", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever 0.39.0", ] [[package]] @@ -1689,6 +3772,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hypher" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef68590049bab63a464eee1a1158ac04c6f6613a546d8d90f78636b8b94f171" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1723,6 +3812,19 @@ dependencies = [ "png 0.17.16", ] +[[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.3.0" @@ -1732,9 +3834,9 @@ dependencies = [ "displaydoc", "potential_utf", "utf8_iter", - "yoke", + "yoke 0.8.3", "zerofrom", - "zerovec", + "zerovec 0.11.8", ] [[package]] @@ -1744,24 +3846,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "litemap 0.8.3", + "tinystr 0.8.4", + "writeable 0.6.4", + "zerovec 0.11.8", ] +[[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.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ - "icu_collections", + "icu_collections 2.3.0", "icu_normalizer_data", - "icu_properties", - "icu_provider", + "icu_properties 2.3.0", + "icu_provider 2.3.1", "smallvec", - "zerovec", + "zerovec 0.11.8", ] [[package]] @@ -1770,6 +3905,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[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.3.0" @@ -1777,20 +3928,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", - "icu_collections", + "icu_collections 2.3.0", "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "icu_properties_data 2.3.0", + "icu_provider 2.3.1", + "zerotrie 0.2.5", + "zerovec 0.11.8", ] +[[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.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +[[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.3.1" @@ -1799,13 +3975,74 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", - "writeable", - "yoke", + "writeable 0.6.4", + "yoke 0.8.3", "zerofrom", - "zerotrie", - "zerovec", + "zerotrie 0.2.5", + "zerovec 0.11.8", ] +[[package]] +name = "icu_provider_adapters" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6324dfd08348a8e0374a447ebd334044d766b1839bb8d5ccf2482a99a77c0bc" +dependencies = [ + "icu_locid", + "icu_locid_transform", + "icu_provider 1.5.0", + "tinystr 0.7.6", + "zerovec 0.10.4", +] + +[[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 2.0.119", +] + +[[package]] +name = "icu_segmenter" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a717725612346ffc2d7b42c94b820db6908048f39434504cb130e8b46256b0de" +dependencies = [ + "core_maths", + "displaydoc", + "icu_collections 1.5.0", + "icu_locid", + "icu_provider 1.5.0", + "icu_segmenter_data", + "serde", + "utf8_iter", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_segmenter_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e52775179941363cc594e49ce99284d13d6948928d8e72c755f55e98caa1eb" + [[package]] name = "ident_case" version = "1.0.1" @@ -1830,7 +4067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", - "icu_properties", + "icu_properties 2.3.0", ] [[package]] @@ -1849,6 +4086,46 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif 0.14.2", + "image-webp", + "moxcms 0.8.1", + "num-traits", + "png 0.18.1", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", +] + +[[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 = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "indexmap" version = "1.9.3" @@ -1916,6 +4193,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8267aa6001e25494f3015f9663bbd88a18240c74483afa5f0934a1b3e4c388e9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -1926,6 +4215,21 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2082,6 +4386,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -2115,6 +4429,15 @@ dependencies = [ "serde_json", ] +[[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 = "keyboard-types" version = "0.7.0" @@ -2126,6 +4449,23 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kqueue" version = "1.2.1" @@ -2146,6 +4486,96 @@ dependencies = [ "libc", ] +[[package]] +name = "krilla" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ddfec86fec13d068075e14f22a7e217c281f3ed69ddcb427bf3f5d504fd674" +dependencies = [ + "base64 0.22.1", + "bumpalo", + "comemo", + "flate2", + "float-cmp 0.10.0", + "gif 0.13.3", + "hayro-write", + "image-webp", + "imagesize 0.14.0", + "indexmap 2.14.0", + "once_cell", + "pdf-writer", + "png 0.17.16", + "rayon", + "rustc-hash 2.1.3", + "rustybuzz", + "siphasher", + "skrifa 0.37.0", + "smallvec", + "subsetter", + "tiny-skia-path", + "xmp-writer", + "yoke 0.8.3", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "krilla-svg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f485e1a850201a01dcd8d73e7cf09f2cd4c4cc85c2cd296359094d49336d8ef7" +dependencies = [ + "flate2", + "fontdb", + "krilla", + "png 0.17.16", + "resvg", + "tiny-skia", + "usvg", +] + +[[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 = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" +dependencies = [ + "fst", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2166,7 +4596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -2195,6 +4625,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.20" @@ -2215,18 +4661,49 @@ dependencies = [ "vcpkg", ] +[[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.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lipsum" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636860251af8963cc40f6b4baadee105f02e21b28131d76eba8e40ce84ab8064" +dependencies = [ + "rand 0.8.8", + "rand_chacha 0.3.1", +] + +[[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.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2242,10 +4719,74 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "louds-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936de6c22f08e7135a921f8ada907acd0d88880c4f42b5591f634b9f1dd8e07f" +dependencies = [ + "fid-rs", +] + +[[package]] +name = "lru" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "macerator" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b0b2dbe8b22f9e96ba12e29964889010117f92e6bd006010887320ae58e2f0" +dependencies = [ + "bytemuck", + "cfg_aliases", + "half", + "macerator-macros", + "moddef", + "num-traits", + "paste", + "rustc_version", +] + +[[package]] +name = "macerator-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5ce85961d618ce9794bdf822bfe96fe9dd341aa5b033b454f7a8d96e79b9b1" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "margin-docs" version = "0.0.1" dependencies = [ + "base64 0.22.1", + "fontdb", + "harper-core", "ignore", "notify", "notify-debouncer-full", @@ -2264,6 +4805,9 @@ dependencies = [ "tempfile", "tokio", "trash", + "typst", + "typst-as-lib", + "typst-pdf", ] [[package]] @@ -2277,12 +4821,49 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", + "stable_deref_trait", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2292,6 +4873,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -2326,6 +4922,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moddef" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0b3262dc837d2513fe2ef31ff8461352ef932dcca31ba0c0abe33547cf6b9b" + +[[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 = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -2347,6 +4969,68 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "naga" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916cbc7cb27db60be930a4e2da243cf4bc39569195f22fd8ee419cd31d5b662c" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap 2.14.0", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2417,12 +5101,76 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2430,6 +5178,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", ] [[package]] @@ -2454,6 +5213,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2684,12 +5452,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.4.1" @@ -2713,6 +5496,26 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", + "rand 0.8.8", + "serde", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2737,6 +5540,39 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "pango" version = "0.18.3" @@ -2791,12 +5627,40 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pdf-writer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a79477295a713c2ed425aa82a8b5d20cec3fdee203706cbe6f3854880c1c81" +dependencies = [ + "bitflags 2.13.1", + "itoa", + "memchr", + "ryu", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + [[package]] name = "phf" version = "0.13.1" @@ -2850,6 +5714,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2881,7 +5751,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.41.0", "serde", "time", ] @@ -2926,11 +5796,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "serde", +] [[package]] name = "portable-atomic-util" @@ -2941,13 +5823,25 @@ dependencies = [ "portable-atomic", ] +[[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.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ - "zerovec", + "zerovec 0.11.8", ] [[package]] @@ -2956,12 +5850,37 @@ 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 = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3015,6 +5934,12 @@ dependencies = [ "version_check", ] +[[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.107" @@ -3024,6 +5949,92 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[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 = "quick-xml" version = "0.41.0" @@ -3054,12 +6065,147 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[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 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "serde", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.5", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +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 = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3129,6 +6275,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.13.4" @@ -3168,6 +6320,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg 0.4.21", +] + [[package]] name = "rfd" version = "0.16.0" @@ -3192,6 +6361,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -3206,6 +6384,49 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[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 = "rs-conllu" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6de5aecf17f8fff1b35d59a12e2b8c908cad4d67208805166483655554f9169" +dependencies = [ + "clap", + "derive_more 1.0.0", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -3231,6 +6452,22 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "num-traits", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3338,6 +6575,60 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ruzstd" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safetensors" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3347,6 +6638,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3443,14 +6743,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.13.1", - "cssparser", - "derive_more", + "cssparser 0.36.0", + "derive_more 2.1.1", "log", "new_debug_unreachable", "phf", "phf_codegen", "precomputed-hash", - "rustc-hash", + "rustc-hash 2.1.3", "servo_arc", "smallvec", ] @@ -3465,6 +6765,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.229" @@ -3487,6 +6793,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -3587,12 +6903,25 @@ version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3673,23 +7002,64 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[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.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[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.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3749,6 +7119,31 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "lock_api", + "portable-atomic", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -3761,12 +7156,46 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable-vec" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "047720fbc1c4cc7daa75c925adba9ce3fa8e1f7b039337c281b142d4ecc6d86b" + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp 0.9.0", +] + [[package]] name = "string_cache" version = "0.9.0" @@ -3797,12 +7226,73 @@ 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 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subsetter" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" +dependencies = [ + "kurbo 0.13.1", + "rustc-hash 2.1.3", + "skrifa 0.42.1", + "write-fonts", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[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 = "swift-rs" version = "1.0.8" @@ -3866,6 +7356,41 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode 1.3.3", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.20", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4180,7 +7705,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -4305,6 +7830,32 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "text_placeholder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5008f74a09742486ef0047596cf35df2b914e2a8dca5727fcb6ba6842a766b" +dependencies = [ + "hashbrown 0.13.2", + "serde", + "serde_json", +] + +[[package]] +name = "thin-vec" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d" + [[package]] name = "thiserror" version = "1.0.69" @@ -4375,6 +7926,52 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[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.4" @@ -4382,7 +7979,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", - "zerovec", + "serde_core", + "zerovec 0.11.8", ] [[package]] @@ -4673,12 +8271,68 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "trie-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f88f4b0a1ebd6c3d16be3e45eb0e8089372ccadd88849b7ca162ba64b5e6f6" +dependencies = [ + "louds-rs", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[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 = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -4691,6 +8345,314 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "typst" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f6511ee598476f4f322b4d13891083d96dbacb8f9c2b908604c7094ba390653" +dependencies = [ + "comemo", + "ecow", + "rustc-hash 2.1.3", + "typst-eval", + "typst-html", + "typst-layout", + "typst-library", + "typst-macros", + "typst-realize", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-as-lib" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc16ea97aecd6bbc7273f33a511b2366e9d513e18c446b73d12a6607e98602e" +dependencies = [ + "chrono", + "comemo", + "dirs", + "ecow", + "thiserror 2.0.20", + "typst", +] + +[[package]] +name = "typst-assets" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5613cb719a6222fe9b74027c3625d107767ec187bff26b8fc931cf58942c834f" + +[[package]] +name = "typst-eval" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687757487dfc0c1e941344d5024cf7a28364e70c3e304faad89ac65597f62526" +dependencies = [ + "comemo", + "ecow", + "indexmap 2.14.0", + "rustc-hash 2.1.3", + "stacker", + "toml 0.8.2", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-segmentation", +] + +[[package]] +name = "typst-html" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e29f8da4f964d4c90739c3c1e0288b0ba1bccc3cc50623a6d558300b86ca8aad" +dependencies = [ + "bumpalo", + "comemo", + "ecow", + "palette", + "rustc-hash 2.1.3", + "time", + "typst-assets", + "typst-library", + "typst-macros", + "typst-svg", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-layout" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cab0200105831a9158e63718a0f6141c78cb2c1722ed17d19ad28941e3b8491" +dependencies = [ + "az", + "bumpalo", + "codex", + "comemo", + "ecow", + "either", + "hypher", + "icu_properties 1.5.1", + "icu_provider 1.5.0", + "icu_provider_adapters", + "icu_provider_blob", + "icu_segmenter", + "kurbo 0.12.0", + "memchr", + "rustc-hash 2.1.3", + "rustybuzz", + "smallvec", + "ttf-parser", + "typst-assets", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", + "unicode-bidi", + "unicode-math-class", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "typst-library" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e276a5de53020c43efe2111ec236252e54ea4480b5ac18063e663dfbe03d9d1b" +dependencies = [ + "az", + "bitflags 2.13.1", + "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 2.14.0", + "kamadak-exif", + "kurbo 0.12.0", + "lipsum", + "memchr", + "palette", + "phf", + "png 0.17.16", + "qcms", + "rayon", + "regex", + "regex-syntax", + "roxmltree", + "rust_decimal", + "rustc-hash 2.1.3", + "rustybuzz", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "smallvec", + "syntect", + "time", + "toml 0.8.2", + "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 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typst-pdf" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c8a4630754767cd10d48e8b8186e7dc784631a30a3a93521edf7d77aebd0c0" +dependencies = [ + "az", + "bytemuck", + "comemo", + "ecow", + "image", + "indexmap 2.14.0", + "infer", + "krilla", + "krilla-svg", + "rustc-hash 2.1.3", + "serde", + "smallvec", + "typst-assets", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-realize" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7ffe964757fb93d2e98978aa2a74ee85b0f94c8643e8f3550737258b58f39d8" +dependencies = [ + "arrayvec", + "bumpalo", + "comemo", + "ecow", + "regex", + "typst-library", + "typst-macros", + "typst-syntax", + "typst-timing", + "typst-utils", +] + +[[package]] +name = "typst-svg" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46b811837ade1f0243ef0d8bf3fb06d166443090eac22c28643f374c2ccdc9d" +dependencies = [ + "base64 0.22.1", + "comemo", + "ecow", + "flate2", + "hayro", + "hayro-svg", + "image", + "rustc-hash 2.1.3", + "ttf-parser", + "typst-assets", + "typst-library", + "typst-macros", + "typst-timing", + "typst-utils", + "xmlparser", + "xmlwriter", +] + +[[package]] +name = "typst-syntax" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a95d9192060e23b1e491b0b94dff676acddc92a4d672aeb8ca3890a5a734e879" +dependencies = [ + "ecow", + "rustc-hash 2.1.3", + "serde", + "toml 0.8.2", + "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 2.1.3", + "siphasher", + "thin-vec", + "unicode-math-class", +] + [[package]] name = "uds_windows" version = "1.2.1" @@ -4723,6 +8685,50 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" +[[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.4", +] + +[[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.4", + "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 2.0.119", + "unic-langid-impl", +] + [[package]] name = "unic-ucd-ident" version = "0.9.0" @@ -4743,24 +8749,117 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[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-blocks" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328edfd6d0bb91a22e592bed3a3b54c7b1f5c92a517f5a7aca24a2caef10c363" + +[[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.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -4792,12 +8891,45 @@ dependencies = [ "url", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "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 = "uuid" version = "1.25.0" @@ -4810,6 +8942,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -4828,6 +8971,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "vswhom" version = "0.1.0" @@ -4950,6 +9099,52 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmi" +version = "0.51.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb321403ce594274827657a908e13d1d9918aa02257b8bf8391949d9764023ff" +dependencies = [ + "spin 0.9.9", + "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.13.1", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -4960,6 +9155,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" @@ -5061,6 +9266,161 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70b6ff82bbf6e9206828e1a3178e851f8c20f1c9028e74dd3a8090741ccd5798" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f62f1053bd28c2268f42916f31588f81f64796e2ff91b81293515017ca8bd9" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18ae5fbde6a4cbebae38358aa73fcd6e0f15c6144b67ef5dc91ded0db125dbdf" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7670e390f416006f746b4600fdd9136455e3627f5bd763abf9a65daa216dd2d" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720a5cb9d12b3d337c15ff0e24d3e97ed11490ff3f7506e7f3d98c68fa5d6f14" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "26.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d0e67224cc7305b3b4eb2cc57ca4c4c3afc665c1d1bee162ea806e19c47bdd" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.15.5", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "ordered-float 4.6.0", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.20", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5117,6 +9477,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5151,6 +9521,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5199,6 +9582,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5221,6 +9615,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5263,6 +9668,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5281,6 +9695,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -5588,6 +10012,25 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types 0.11.3", + "indexmap 2.14.0", + "kurbo 0.13.1", + "log", + "read-fonts 0.39.2", +] + +[[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.4" @@ -5669,6 +10112,51 @@ dependencies = [ "rustix", ] +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "xmp-writer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" + +[[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.3" @@ -5676,10 +10164,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", - "yoke-derive", + "yoke-derive 0.8.2", "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 2.0.119", + "synstructure", +] + [[package]] name = "yoke-derive" version = "0.8.2" @@ -5762,6 +10262,26 @@ dependencies = [ "serde", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5789,6 +10309,18 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[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.5" @@ -5796,19 +10328,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", - "yoke", + "yoke 0.8.3", "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.4", +] + [[package]] name = "zerovec" version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ - "yoke", + "serde", + "yoke 0.8.3", "zerofrom", - "zerovec-derive", + "zerovec-derive 0.11.6", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3c6377872d72510393f688a555d7097b0f741995c7a00f0407f786dd486b2d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -5834,12 +10390,114 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "crc32fast", + "indexmap 2.14.0", + "memchr", + "typed-path", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.3", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2ced78d..b071134 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,13 +33,45 @@ trash = "5" rusqlite = { version = "0.40", features = ["bundled"] } tokio = { version = "1", features = ["sync", "time"] } +# PDF export. Typst is the typesetter: a document becomes Typst source, the source is compiled in +# process and the bytes go to whatever the native save panel pointed at. No headless browser, no +# LaTeX install, nothing for the user to have on their machine first. +# +# typst and typst-pdf move together and are pinned to the same minor, because typst-as-lib links +# against a particular pair and a mismatched trio does not compile. fontdb finds a system face for +# the one thing this app does not bundle, a monospace family for code. +typst = "0.14.2" +typst-pdf = "0.14.2" +typst-as-lib = "0.15.5" +fontdb = "0.23" +# Inline image bytes arrive over the IPC boundary as base64: a mermaid diagram is rendered to SVG +# in the webview and has no file behind it to read. +base64 = "0.22" + +# Grammar, which is Harper's and is the one checker this app does ship. Spelling stays the system's +# below; Harper's own spell rule is turned off in grammar.rs for that reason. +# +# Pinned exactly: the [patch] stubs at the foot of this file are tied to this version's burn/cubecl +# graph. A minor bump could silently invalidate a patch ("unused"), and the whole CUDA and LLVM +# subtree those stubs remove would come back. Bump deliberately and re-audit the stubs. +harper-core = { version = "=2.5.0", features = ["concurrent"] } + # Spelling is the system's, not ours. NSSpellChecker is the same checker every other Mac app # corrects into, so a word learned in Mail is not underlined here, and it carries the user's own # languages without this app shipping a dictionary. These objc2 crates are already in the graph # through Tauri, so asking for them adds nothing to the build but the features named. [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" -objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] } +# NSMenu and its neighbours are for Writing Tools, which has no API this app can call: the system +# puts a submenu on Edit and the only way in is to find that item and perform it. Deliberately not +# NSWritingToolsCoordinator, which the sibling asks for and never uses. +objc2-app-kit = { version = "0.3", features = [ + "NSSpellChecker", + "NSApplication", + "NSMenu", + "NSMenuItem", + "NSResponder", +] } objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] } # There is no auto-updater and no process to restart on a phone: the store is the update channel. @@ -50,3 +82,21 @@ tauri-plugin-updater = "2" [dev-dependencies] tempfile = "3" + +# harper-core transitively declares optional, disabled GPU backends through burn: a `burn-cuda` +# CUDA backend and, under cubecl, a `cubecl-cpu` LLVM/MLIR JIT runtime. Both are off, and neither +# is ever compiled, but Cargo still version-resolves and downloads the whole dead subtree behind +# them, which is the cuda toolchain crates on one side and tracel-llvm plus its LLVM bundler on the +# other. Replacing the two roots with empty stubs takes several hundred crates out of the graph and +# a large part of the build with them. +[patch.crates-io] +burn-cuda = { path = "stubs/burn-cuda" } +cubecl-cpu = { path = "stubs/cubecl-cpu" } + +# Optimize dependencies even in dev builds. Harper's grammar engine, and the burn-ndarray POS +# tagger under it, is roughly ten times slower unoptimized, which is the difference between a check +# that lands while the user is still typing the next word and one that takes seconds. This compiles +# dependencies at opt-level 3 and leaves this crate itself unoptimized, so incremental rebuilds of +# our own code stay fast. The first build after adding it is slower, once. +[profile.dev.package."*"] +opt-level = 3 diff --git a/src-tauri/fonts/HankenGrotesk-Bold.ttf b/src-tauri/fonts/HankenGrotesk-Bold.ttf new file mode 100644 index 0000000..5c5d4d3 Binary files /dev/null and b/src-tauri/fonts/HankenGrotesk-Bold.ttf differ diff --git a/src-tauri/fonts/HankenGrotesk-BoldItalic.ttf b/src-tauri/fonts/HankenGrotesk-BoldItalic.ttf new file mode 100644 index 0000000..3b411cf Binary files /dev/null and b/src-tauri/fonts/HankenGrotesk-BoldItalic.ttf differ diff --git a/src-tauri/fonts/HankenGrotesk-Italic.ttf b/src-tauri/fonts/HankenGrotesk-Italic.ttf new file mode 100644 index 0000000..3c012b1 Binary files /dev/null and b/src-tauri/fonts/HankenGrotesk-Italic.ttf differ diff --git a/src-tauri/fonts/HankenGrotesk-Regular.ttf b/src-tauri/fonts/HankenGrotesk-Regular.ttf new file mode 100644 index 0000000..e520038 Binary files /dev/null and b/src-tauri/fonts/HankenGrotesk-Regular.ttf differ diff --git a/src-tauri/fonts/Literata-Bold.ttf b/src-tauri/fonts/Literata-Bold.ttf new file mode 100644 index 0000000..ccaa5f9 Binary files /dev/null and b/src-tauri/fonts/Literata-Bold.ttf differ diff --git a/src-tauri/fonts/Literata-BoldItalic.ttf b/src-tauri/fonts/Literata-BoldItalic.ttf new file mode 100644 index 0000000..f5f88c0 Binary files /dev/null and b/src-tauri/fonts/Literata-BoldItalic.ttf differ diff --git a/src-tauri/fonts/Literata-Italic.ttf b/src-tauri/fonts/Literata-Italic.ttf new file mode 100644 index 0000000..dc7ef4e Binary files /dev/null and b/src-tauri/fonts/Literata-Italic.ttf differ diff --git a/src-tauri/fonts/Literata-Regular.ttf b/src-tauri/fonts/Literata-Regular.ttf new file mode 100644 index 0000000..2b18d3e Binary files /dev/null and b/src-tauri/fonts/Literata-Regular.ttf differ diff --git a/src-tauri/fonts/Literata-SemiBold.ttf b/src-tauri/fonts/Literata-SemiBold.ttf new file mode 100644 index 0000000..02eb58d Binary files /dev/null and b/src-tauri/fonts/Literata-SemiBold.ttf differ diff --git a/src-tauri/fonts/PROVENANCE.md b/src-tauri/fonts/PROVENANCE.md new file mode 100644 index 0000000..5ac5590 --- /dev/null +++ b/src-tauri/fonts/PROVENANCE.md @@ -0,0 +1,31 @@ +# Where these faces came from + +Nine static instances, cut from the four variable fonts in `public/fonts/` that the editor renders +with. Same designs, same licence, same bytes underneath: the only difference is that a weight axis +has been pinned rather than left open. + +They exist because Typst does not support a variable axis. It warns that it does not, then lays the +text out at the default instance whatever weight was asked for, so a PDF set from the variable files +has its headings, its bold runs and its callout labels all at 400 and no visible hierarchy at all. + +They live here rather than in `public/fonts/` because nothing in the webview loads them. Everything +under `public/` is copied into the frontend bundle, and these are compiled into the binary by +`src-tauri/src/pdf.rs`, so keeping them here ships them once instead of twice. + +To regenerate after updating a variable font, with fonttools installed: + +```python +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer + +font = TTFont("public/fonts/Literata-VF.ttf") +instancer.instantiateVariableFont(font, {"wght": 600, "opsz": 12}, inplace=True) +# then set name IDs 1, 2, 4, 6, 16 and 17 to the family and style, and save. +font.save("src-tauri/fonts/Literata-SemiBold.ttf") +``` + +Literata pins `opsz` to 12, which is its own default and the size the body text is set at. The +weights are 400, 600 and 700 upright and 400 and 700 italic for Literata, and 400 and 700 in both +slopes for Hanken Grotesk, which are the weights `src/export/typst.ts` asks for. Typst falls back to +the nearest weight it has, so a face that is missing is a heading that comes out too heavy rather +than an export that fails. diff --git a/src-tauri/src/dto.rs b/src-tauri/src/dto.rs index 93bf266..8b1a295 100644 --- a/src-tauri/src/dto.rs +++ b/src-tauri/src/dto.rs @@ -183,3 +183,61 @@ pub struct SpellIssue { #[serde(default)] pub suggestions: Vec, } + +/// An image the PDF exporter has to put on a page. +/// +/// `data` present means the bytes came with the request, base64 encoded, which is the only way a +/// mermaid diagram can arrive: the frontend renders it to SVG and there is no file behind it and +/// never will be. `data` absent means read the file at `path`, which is what an ordinary +/// `![](photo.jpg)` is, and it is absent far more often than not: base64 encoding every photograph +/// in a document through the IPC boundary costs a third again in bytes for a file the backend can +/// already open. +/// +/// A read goes through the same root guard every other read in fs.rs does. A document is untrusted +/// input. `![](../../../.ssh/id_rsa)` is a link anybody can type into a markdown file, and an +/// exporter is not the place where this app starts reading outside an open folder. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImageInput { + /// How the Typst source refers to it, which is also the key the file resolver answers on. + pub path: String, + #[serde(default)] + pub data: Option, +} + +/// Something the exporter worked around rather than something it refused to do. Payload of the +/// `pdf-warnings` event, which is how these travel: a compile answers with raw bytes and has +/// nowhere to put a second value. +/// +/// `count` is here because the alternative is forty toasts. A document with forty formulas the +/// converter could not typeset has one problem, not forty, and the user wants to be told once with +/// a number on it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PdfWarning { + /// math | image | typst + pub kind: String, + pub message: String, + pub count: u32, +} + +/// One grammar problem in a run of text handed to the checker. +/// +/// `start` and `end` are half-open offsets in *characters*, not bytes and not UTF-16 units, for +/// exactly the reason `SpellIssue` gives: the other end is JavaScript addressing a ProseMirror +/// document, and ProseMirror counts in code points. Harper already counts that way, so unlike the +/// spelling path there is no conversion to do and nowhere for one to go wrong. +/// +/// `kind` is Harper's own name for the rule that fired, which is what the popover shows above the +/// message so a correction can be judged before it is taken. `suggestions` can be empty: a rule +/// that can see a sentence is wrong without knowing how to fix it is still worth an underline. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GrammarIssue { + pub start: usize, + pub end: usize, + pub kind: String, + pub message: String, + #[serde(default)] + pub suggestions: Vec, +} diff --git a/src-tauri/src/fonts.rs b/src-tauri/src/fonts.rs new file mode 100644 index 0000000..a8aea8b --- /dev/null +++ b/src-tauri/src/fonts.rs @@ -0,0 +1,143 @@ +// The faces a PDF is set in. +// +// Nine of them are bundled and compiled into the binary by pdf.rs, one per weight and slope, +// because a document that typesets differently on two machines is not an export. The two things this app does not bundle +// are a monospace family for code and a math family for formulas, both of which are large and +// neither of which the editor shows on screen, so they come off the system instead and this module +// is how they are found. +// +// A family that is not installed is not an error. Typst is handed whatever was found and falls +// back through the rest of its book for anything missing, and the export still happens. + +use std::collections::HashSet; + +use fontdb::{Database, Family, Query}; + +/// Monospace families in descending order of preference, starting with the name Typst's `raw` +/// element asks for by default so that a machine which happens to have it needs no help. The rest +/// are what macOS, Windows and the common Linux desktops actually ship. +const MONOSPACE_FAMILIES: [&str; 8] = [ + "DejaVu Sans Mono", + "SF Mono", + "Menlo", + "Monaco", + "Andale Mono", + "Consolas", + "Liberation Mono", + "Courier New", +]; + +/// Math families, again starting with Typst's own default. A math face is not interchangeable with +/// a text one: laying out an equation needs the OpenType MATH table, and Typst will not fall back +/// off this list on its own, so it is long on purpose. +const MATH_FAMILIES: [&str; 8] = [ + "New Computer Modern Math", + "Latin Modern Math", + "STIX Two Math", + "Cambria Math", + "XITS Math", + "TeX Gyre Pagella Math", + "DejaVu Math TeX Gyre", + "Asana Math", +]; + +/// Loading the system font list walks several directories, so it happens once per compile rather +/// than once per family looked up. +fn system_db() -> Database { + let mut db = Database::new(); + db.load_system_fonts(); + db +} + +/// A key that is the same for every face of one font collection, so a `.ttc` is read once instead +/// of once per style. `with_face_data` hands back the whole collection either way, and Typst +/// expands it into every face it holds. +fn source_key(face: &fontdb::FaceInfo) -> String { + match &face.source { + fontdb::Source::File(path) => path.to_string_lossy().into_owned(), + fontdb::Source::SharedFile(path, _) => path.to_string_lossy().into_owned(), + fontdb::Source::Binary(_) => format!("{:?}:{}", face.id, face.index), + } +} + +fn installed(face: &fontdb::FaceInfo, family: &str) -> bool { + face.families + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(family)) +} + +fn faces_for(db: &Database, family: &str, into: &mut Vec>, seen: &mut HashSet) { + let styles = [ + (fontdb::Weight::NORMAL, fontdb::Style::Normal), + (fontdb::Weight::NORMAL, fontdb::Style::Italic), + (fontdb::Weight::BOLD, fontdb::Style::Normal), + (fontdb::Weight::BOLD, fontdb::Style::Italic), + ]; + for (weight, style) in styles { + let query = Query { + families: &[Family::Name(family)], + weight, + style, + ..Query::default() + }; + let Some(id) = db.query(&query) else { continue }; + // fontdb answers a query with its closest match rather than with nothing, so a family that + // is not installed comes back as some unrelated face. Checking the name of what came back + // is the only way to tell a hit from a substitution. + let Some(face) = db.face(id) else { continue }; + if !installed(face, family) || !seen.insert(source_key(face)) { + continue; + } + if let Some(bytes) = db.with_face_data(id, |data, _index| data.to_vec()) { + into.push(bytes); + } + } +} + +/// What this machine turned out to have. +/// +/// The names matter as much as the bytes. Typst warns once per family it was asked for and could +/// not find, so a preamble naming a hopeful list of eight monospaces produces seven warnings on a +/// machine with one of them, and the export ends with a toast about fonts nobody chose. Naming only +/// what is here means naming nothing that is not. +pub struct Fallbacks { + pub fonts: Vec>, + pub monospace: Vec, + pub math: Vec, +} + +/// The faces the bundle is missing: a monospace for code blocks and a math family for formulas. +/// +/// Every candidate that is installed is loaded, not just the first, so that the preamble can name +/// them in preference order and let Typst pick. +pub fn fallbacks() -> Fallbacks { + let db = system_db(); + let mut fonts = Vec::new(); + let mut seen = HashSet::new(); + let monospace = collect_installed(&db, &MONOSPACE_FAMILIES, &mut fonts, &mut seen); + let math = collect_installed(&db, &MATH_FAMILIES, &mut fonts, &mut seen); + Fallbacks { + fonts, + monospace, + math, + } +} + +fn collect_installed( + db: &Database, + families: &[&str], + fonts: &mut Vec>, + seen: &mut HashSet, +) -> Vec { + families + .iter() + .filter(|family| { + let before = fonts.len(); + faces_for(db, family, fonts, seen); + // A family whose file was already loaded under another name is still installed, so the + // count is not the test on its own. + fonts.len() > before || db.faces().any(|face| installed(face, family)) + }) + .map(|family| (*family).to_string()) + .collect() +} diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 91f2af6..0765424 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -670,6 +670,22 @@ pub fn duplicate_entry(path: &Path) -> Result { /// Never `fs::remove_file`. These are the user's own documents and this app does not get to be the /// reason one of them is gone for good. pub fn trash_entry(path: &Path) -> Result<(), String> { + // Not `trash::delete`, whose macOS default asks Finder to do it over an Apple event. That is + // the method that leaves Put Back on the file, and it is the wrong trade here: an Apple event + // from a hardened runtime needs an entitlement and a one time permission prompt, and a delete + // that fails because the user said no to a dialog about controlling Finder is a worse answer + // than a delete with no Put Back. `trashItemAtURL:` asks nobody, makes no sound and is faster. + // A file trashed this way is still in the Trash and can still be dragged back out. + #[cfg(target_os = "macos")] + { + use trash::macos::{DeleteMethod, TrashContextExtMacos}; + let mut context = trash::TrashContext::default(); + context.set_delete_method(DeleteMethod::NsFileManager); + context + .delete(path) + .map_err(|e| format!("{}: {e}", path.display())) + } + #[cfg(not(target_os = "macos"))] trash::delete(path).map_err(|e| format!("{}: {e}", path.display())) } diff --git a/src-tauri/src/grammar.rs b/src-tauri/src/grammar.rs new file mode 100644 index 0000000..acab468 --- /dev/null +++ b/src-tauri/src/grammar.rs @@ -0,0 +1,128 @@ +// Grammar, which is Harper's. +// +// Deliberately not a port of the sibling's proofing.rs. That file checks spelling and grammar +// together because it had to ship its own speller; here NSSpellChecker already does the spelling +// in spell.rs and macspell.rs, and Harper's own spell rule stays off for the same reason: the +// system checker knows the user's names, their languages and every word they have ever taught +// their Mac, and a second opinion from a bundled dictionary is a worse one. +// +// The engine is built once and kept. Building it reads a curated dictionary and a part of speech +// model out of the binary, which is far too much work to repeat per paragraph, and this module +// keeps it in a static rather than in `tauri::Manager` state because a `LintGroup` is not the kind +// of thing the rest of the crate has any business reaching. Built on the first check rather than +// at launch, so a window opens without waiting for a model nobody has asked a question of yet. +// +// Nothing here is on the main thread. `grammar_check` is a `#[tauri::command(async)]`, so a long +// paragraph does not hold the window still while it is linted, which matters here for the same +// reason it matters in spell.rs: this runs while the user is typing. + +use std::sync::{Arc, LazyLock, Mutex}; + +use harper_core::linting::{LintGroup, Linter, Suggestion}; +use harper_core::spell::FstDictionary; +use harper_core::{Dialect, Document}; + +use crate::dto::GrammarIssue; + +/// What the popover offers, which is what the spelling menu beside it offers. +const MAX_SUGGESTIONS: usize = 5; + +/// The linter and the dictionary it was built against, which `Document` needs as well. +struct Harper { + linter: LintGroup, + dict: Arc, +} + +/// Built on first use and kept for the life of the process. The `Mutex` is not about sharing: it +/// is that linting takes `&mut self`, and two paragraphs arriving at once would otherwise have +/// nowhere to queue. +static ENGINE: LazyLock> = LazyLock::new(|| Mutex::new(build_harper())); + +fn build_harper() -> Harper { + let dict = FstDictionary::curated(); + let mut linter = LintGroup::new_curated(dict.clone(), Dialect::American); + // The system checker does the spelling, and it does it better: see the note at the top. + linter.config.set_rule_enabled("SpellCheck", false); + Harper { linter, dict } +} + +/// Whether this build has a grammar engine behind it. +/// +/// A compile-time fact, answered the way `spell_available` answers its own: harper-core is a +/// dependency of this crate or it is not, and on a build where it is there is no runtime state in +/// which the engine has gone missing. Deliberately does not touch `ENGINE`, because the frontend +/// asks this once at launch and forcing the model load here would put the whole of it in front of +/// the first window for an answer that is already known. +#[tauri::command] +pub fn grammar_available() -> Result { + Ok(true) +} + +/// Every grammar problem in one run of text, with offsets in characters counted from the start of +/// that run. +/// +/// Like `spell_check`, it is told about a paragraph and answers about that paragraph: it has no +/// idea a document exists, and the caller adds its own base offset afterwards. Harper counts in +/// characters already, so unlike the spelling path there is no conversion here and nowhere for one +/// to go wrong. +#[tauri::command(async)] +pub fn grammar_check(text: String) -> Result, String> { + let mut engine = ENGINE.lock().map_err(|e| e.to_string())?; + Ok(collect_grammar(&mut engine, &text)) +} + +fn collect_grammar(harper: &mut Harper, text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let doc = Document::new_plain_english(text, harper.dict.as_ref()); + + let mut issues = Vec::new(); + for lint in harper.linter.lint(&doc) { + // Clamped end first and then start against it, so a span this side never indexes past the + // text and never comes out inverted. Harper should not hand back either, but this is a + // foreign engine walking the user's prose and a slice with a bad pair of bounds is a panic + // rather than a wrong underline. + let end = lint.span.end.min(chars.len()); + let start = lint.span.start.min(end); + let existing: String = chars[start..end].iter().collect(); + + // A lint about nothing but whitespace is dropped, and Harper does produce them: two spaces + // between sentences are "French spaces" to it, and the suggestion is one space. There is + // nothing worth drawing there. An underline over characters that are not visible is not + // visible either, it cannot be clicked to reach the offer behind it, and on the caller's + // side those spaces are frequently not the user's at all: src/editor/proofing.ts blanks + // inline code spans and leaf nodes into runs of spaces of the same width so that the words + // either side keep their positions, and a checker underlining those would be underlining + // exactly the thing that file went to the trouble of refusing to send. + if existing.trim().is_empty() { + continue; + } + + let mut suggestions = Vec::new(); + for suggestion in &lint.suggestions { + match suggestion { + Suggestion::ReplaceWith(replacement) => { + suggestions.push(replacement.iter().collect()) + } + // An insertion is offered as the whole of what the span would become, because the + // popover replaces the underlined text with whatever is chosen and knows nothing + // about the shape of the edit behind it. + Suggestion::InsertAfter(insertion) => { + suggestions.push(format!("{existing}{}", insertion.iter().collect::())) + } + Suggestion::Remove => suggestions.push(String::new()), + } + } + suggestions.truncate(MAX_SUGGESTIONS); + + issues.push(GrammarIssue { + start, + end, + // Harper's own name for the category the rule falls into, which is what the popover + // shows above the message so a correction can be judged before it is taken. + kind: format!("{:?}", lint.lint_kind), + message: lint.message, + suggestions, + }); + } + issues +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 90dd99a..f7c58f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,15 @@ pub mod dto; +pub mod fonts; pub mod fs; +pub mod grammar; pub mod index; mod library; #[cfg(target_os = "macos")] mod macspell; +pub mod pdf; pub mod spell; pub mod watch; +pub mod writingtools; use std::sync::Mutex; @@ -57,6 +61,9 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result let save = MenuItemBuilder::with_id("save", "Save") .accelerator("CmdOrCtrl+S") .build(handle)?; + let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…") + .accelerator("CmdOrCtrl+Shift+E") + .build(handle)?; let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder").build(handle)?; let check_updates = MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?; @@ -99,6 +106,7 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result &command_palette, &PredefinedMenuItem::separator(handle)?, &save, + &export_pdf, &PredefinedMenuItem::separator(handle)?, &close_folder, &PredefinedMenuItem::separator(handle)?, @@ -114,6 +122,7 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result .item(&command_palette) .item(&PredefinedMenuItem::separator(handle)?) .item(&save) + .item(&export_pdf) .item(&PredefinedMenuItem::separator(handle)?) .item(&close_folder) .build()?; @@ -140,6 +149,28 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result app_submenu.insert(&settings, 3)?; app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?; } + // Writing Tools, which is the system's and needs macOS 15.1 with Apple Intelligence on. + // + // These two rows carry the chords and Apple's own Writing Tools rows deliberately do not, + // which is the opposite of what the sibling app does. AppKit performs a key equivalent by + // firing its menu item directly, so a chord on the system's row would reach Writing Tools + // without passing the selection guard in src/editor/writing.ts, and that guard exists + // because a rewrite spanning a link loses its address and one spanning two table cells + // widens the table. These ids go through the command table, so they meet the guard. + // Exactly one menu item owns each chord either way; this is which one. + if let Some(edit) = find_submenu("Edit") { + let proofread = MenuItemBuilder::with_id("writing-proofread", "Proofread") + .accelerator("Shift+Alt+F") + .build(handle)?; + let rewrite = MenuItemBuilder::with_id("writing-rewrite", "Rewrite") + .accelerator("Shift+Alt+R") + .build(handle)?; + edit.append_items(&[ + &PredefinedMenuItem::separator(handle)?, + &proofread, + &rewrite, + ])?; + } if let Some(view) = find_submenu("View") { let toggle_sidebar = MenuItemBuilder::with_id("toggle-sidebar", "Toggle Sidebar") .accelerator("CmdOrCtrl+\\") @@ -193,6 +224,10 @@ pub fn run() { if let Err(e) = index::open(app.handle()) { eprintln!("failed to open the search index: {e}"); } + // The Writing Tools submenu is AppKit's, not build_menu's: the system inserts it into Edit + // on its own terms, so when it is there to label is writingtools.rs's problem and not this + // file's. One call, whatever it decides to wait for. + writingtools::install(app.handle()); Ok(()) }); @@ -216,6 +251,9 @@ pub fn run() { | "toggle-sidebar" | "check-updates" | "report-issue" + | "export-pdf" + | "writing-proofread" + | "writing-rewrite" ) { app.emit("menu-action", event.id().0.as_str()).ok(); } @@ -253,6 +291,12 @@ pub fn run() { spell::spell_learn, spell::spell_unlearn, spell::spell_available, + writingtools::writing_available, + writingtools::writing_run, + pdf::pdf_compile, + pdf::pdf_write, + grammar::grammar_available, + grammar::grammar_check, ]) .run(context) .expect("error while running Margin Docs"); diff --git a/src-tauri/src/pdf.rs b/src-tauri/src/pdf.rs new file mode 100644 index 0000000..c488c9d --- /dev/null +++ b/src-tauri/src/pdf.rs @@ -0,0 +1,387 @@ +// PDF export: Typst source in, PDF bytes out, compiled in this process. +// +// Nothing about this touches the user's markdown. The converter that produces the source lives in +// src/export/typst.ts and reads the ProseMirror document; this module compiles what it is given +// and writes the result where a native save panel pointed. A document that has never been saved +// exports exactly as well as one that has. +// +// The compiler sees no filesystem and no network. Everything it can open is put in front of it by +// hand: the nine bundled faces, the images this call was handed, and a vendored copy of mitex +// served at `/mitex/`. That is the whole world, so a document cannot make the exporter fetch a +// package, and the export works on a machine that has never been online. + +use std::path::Path; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use tauri::{Emitter, Manager}; +use typst::diag::{Severity, SourceDiagnostic, Warned}; +use typst::layout::PagedDocument; +use typst_as_lib::TypstEngine; + +use crate::dto::{ImageInput, PdfWarning}; + +// The faces a document is set in, one file per weight and slope. +// +// Not the variable fonts in public/fonts/ that the editor itself renders with, and that is not +// duplication for its own sake: Typst does not support a variable axis, warns that it does not, and +// lays the text out at the default instance regardless of the weight asked for. Every heading, every +// bold run and every callout label would come out at 400, which is a PDF where the hierarchy the +// author can see on screen is gone. These nine are static instances cut from those same four files; +// src-tauri/fonts/PROVENANCE.md is how, and is what to repeat when a face is updated. +static FACES: [&[u8]; 9] = [ + include_bytes!("../fonts/Literata-Regular.ttf"), + include_bytes!("../fonts/Literata-SemiBold.ttf"), + include_bytes!("../fonts/Literata-Bold.ttf"), + include_bytes!("../fonts/Literata-Italic.ttf"), + include_bytes!("../fonts/Literata-BoldItalic.ttf"), + include_bytes!("../fonts/HankenGrotesk-Regular.ttf"), + include_bytes!("../fonts/HankenGrotesk-Bold.ttf"), + include_bytes!("../fonts/HankenGrotesk-Italic.ttf"), + include_bytes!("../fonts/HankenGrotesk-BoldItalic.ttf"), +]; + +// mitex 0.2.5, vendored under src-tauri/vendor/mitex with its LICENSE, and served as ordinary +// paths under `/mitex/` rather than through Typst's package system. A package spec would mean a +// download on first export and a cache directory to keep, for a dependency that is 380K and never +// changes. Every file in the package has to be here: lib.typ imports mitex.typ relatively, that +// imports specs/mod.typ, and mitex.typ loads the wasm module beside it. +static MITEX_LIB: &str = include_str!("../vendor/mitex/lib.typ"); +static MITEX_MAIN: &str = include_str!("../vendor/mitex/mitex.typ"); +static MITEX_SPECS: &str = include_str!("../vendor/mitex/specs/mod.typ"); +static MITEX_PRELUDE: &str = include_str!("../vendor/mitex/specs/prelude.typ"); +static MITEX_LATEX: &str = include_str!("../vendor/mitex/specs/latex/standard.typ"); +static MITEX_WASM: &[u8] = include_bytes!("../vendor/mitex/mitex.wasm"); + +/// The import path the generated source uses, and the contract with src/export/typst.ts: +/// `#import "/mitex/lib.typ": mitex, mi`. +const MITEX_ROOT: &str = "/mitex/"; + +/// What `/mitex/lib.typ` answers with on a second attempt, after a formula stopped the first one. +/// +/// It keeps the names the generated source imports and sets every formula as the LaTeX the user +/// wrote, which is the readable thing to do with a formula nothing can typeset. Nothing here loads +/// the wasm module, because that is what failed. +static MITEX_PLAIN_LIB: &str = r#"#let mitex-source(it) = { + if type(it) == str { it } else if type(it) == content and it.has("text") { it.text } else { repr(it) } +} +#let mi(it, ..args) = raw(mitex-source(it)) +#let mimath(it, ..args) = raw(mitex-source(it)) +#let mitext(it) = raw(mitex-source(it)) +#let mitex(it, mode: "math", ..args) = block(raw(mitex-source(it))) +#let mitex-convert(it, mode: "math", spec: none) = mitex-source(it) +"#; + +/// A 1x1 transparent image in each format Typst picks from a file extension, so that an image the +/// exporter could not read leaves a gap on the page rather than killing the export. +/// +/// One per format because Typst trusts the extension over the bytes: handing PNG bytes to +/// `#image("photo.jpg")` is a decode error, which is the hard failure this exists to avoid. The +/// formats Typst does not recognise from an extension fall through to sniffing the data, so PNG is +/// the right default for those. +const PLACEHOLDER_PNG: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AwAI/AL+p5qgoAAAAABJRU5ErkJggg=="; +const PLACEHOLDER_JPEG: &str = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AR//Z"; +const PLACEHOLDER_GIF: &str = "R0lGODdhAQABAIEAAP///wAAAAAAAAAAACwAAAAAAQABAAAIBAABBAQAOw=="; +const PLACEHOLDER_WEBP: &str = + "UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP789AAA"; +const PLACEHOLDER_SVG: &str = ""; + +fn placeholder_for(path: &str) -> Vec { + let extension = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_lowercase(); + let encoded = match extension.as_str() { + "jpg" | "jpeg" => PLACEHOLDER_JPEG, + "gif" => PLACEHOLDER_GIF, + "webp" => PLACEHOLDER_WEBP, + "svg" | "svgz" => return PLACEHOLDER_SVG.as_bytes().to_vec(), + _ => PLACEHOLDER_PNG, + }; + STANDARD.decode(encoded).unwrap_or_default() +} + +/// The bytes of one image, either the ones that came with the request or the ones on disk. +/// +/// A read is guarded exactly as every read in fs.rs is: the path is resolved and has to land +/// inside a folder the user actually opened. A document is untrusted input, `![](../../../.ssh/id_rsa)` +/// is a link anybody can type, and an exporter is not where this app starts reading outside an +/// open folder. The path has to be absolute for that check to mean anything, which is why the +/// converter sends an absolute one for any image it has no bytes for. +fn image_bytes(image: &ImageInput, root_paths: &[String]) -> Result, String> { + match &image.data { + Some(data) => STANDARD + .decode(data.as_bytes()) + .map_err(|e| format!("could not decode \"{}\": {e}", image.path)), + None => { + let path = crate::fs::resolve_in_roots(root_paths, &image.path)?; + std::fs::read(&path).map_err(|e| format!("could not read \"{}\": {e}", image.path)) + } + } +} + +fn font_list(families: &[String], last_resort: &str) -> String { + let mut names: Vec = families.iter().map(|f| format!("\"{f}\"")).collect(); + names.push(format!("\"{last_resort}\"")); + format!("({})", names.join(", ")) +} + +/// The only Typst this module writes, and it names font families and nothing else. +/// +/// It belongs here rather than in the converter because which faces the compiler can see is this +/// module's business: nine bundled, plus whichever monospace and math families the machine turned +/// out to have. src/export/typst.ts names none of them for exactly that reason. +/// +/// Math is the one that cannot be left alone. Typst sets every equation to the single family +/// "New Computer Modern Math" and switches font fallback off while it does it, so on a machine +/// without that family one formula is a hard compile error rather than a warning. Naming a list is +/// what makes a formula typeset at all, and Literata closes both lists so that a machine with none +/// of the candidates gets a page that reads badly and a warning saying so, instead of no PDF. +/// +/// Only families that are actually installed are named, because Typst warns once for every family +/// it was asked for and could not find, and a hopeful list would end every export with a toast +/// about fonts nobody chose. +fn font_preamble(fallbacks: &crate::fonts::Fallbacks) -> String { + format!( + "#set text(font: \"Literata\")\n\ + #show raw: set text(font: {})\n\ + #show math.equation: set text(font: {})\n", + font_list(&fallbacks.monospace, "Literata"), + font_list(&fallbacks.math, "Literata"), + ) +} + +/// Adds a warning, or bumps the count of one already there. +/// +/// Forty formulas that would not typeset are one problem and not forty toasts, and the same +/// message arriving twice is the overwhelmingly common case: one broken construct repeated down a +/// document. +fn note(warnings: &mut Vec, kind: &str, message: String) { + if let Some(existing) = warnings + .iter_mut() + .find(|w| w.kind == kind && w.message == message) + { + existing.count += 1; + return; + } + warnings.push(PdfWarning { + kind: kind.to_string(), + message, + count: 1, + }); +} + +fn in_mitex(span: typst::syntax::Span) -> bool { + span.id().is_some_and(|id| { + id.vpath() + .as_rooted_path() + .to_string_lossy() + .starts_with(MITEX_ROOT) + }) +} + +fn touches_mitex(diagnostic: &SourceDiagnostic) -> bool { + in_mitex(diagnostic.span) || diagnostic.trace.iter().any(|point| in_mitex(point.span)) +} + +/// Which kind a diagnostic gets, or `None` for one the user should never see. +/// +/// A warning raised inside the vendored mitex sources with nothing in its trace leading back out +/// of them is mitex talking about itself: a deprecation in a pinned copy of a dependency, the same +/// on every machine and in every document, and nothing anybody reading a toast can act on. One +/// with the document in its trace is a formula that did not come out right, which is a "math" +/// warning and is worth saying. +fn kind_for(diagnostic: &SourceDiagnostic) -> Option<&'static str> { + if !in_mitex(diagnostic.span) { + return Some("typst"); + } + if diagnostic.trace.iter().any(|point| !in_mitex(point.span)) { + return Some("math"); + } + None +} + +/// One pass over the compiler. `lib` is what `/mitex/lib.typ` answers with, which is the whole +/// difference between a formula that typesets and one that is shown as the source the user wrote. +/// +/// The one error in this crate that is not a `String`, and deliberately. The caller has to ask +/// whether the failure came from inside mitex before it decides whether a second attempt is worth +/// making, and that question is asked of the diagnostics' spans. Formatting them first would throw +/// away the only thing the answer depends on. +fn compile_once( + source: &str, + lib: &str, + binaries: &[(&str, Vec)], + fonts: &[Vec], +) -> Result<(Vec, Vec), Vec> { + let engine = TypstEngine::builder() + .main_file(source) + .fonts(fonts.iter().map(|font| font.as_slice())) + .with_static_source_file_resolver([ + ("/mitex/lib.typ", lib), + ("/mitex/mitex.typ", MITEX_MAIN), + ("/mitex/specs/mod.typ", MITEX_SPECS), + ("/mitex/specs/prelude.typ", MITEX_PRELUDE), + ("/mitex/specs/latex/standard.typ", MITEX_LATEX), + ]) + .with_static_file_resolver(binaries.iter().map(|(path, bytes)| (*path, bytes.as_slice()))) + .build(); + + let Warned { output, warnings } = engine.compile(); + let document: PagedDocument = output.map_err(|e| match e { + typst_as_lib::TypstAsLibError::TypstSource(diagnostics) => diagnostics.into_iter().collect(), + other => vec![SourceDiagnostic::error( + typst::syntax::Span::detached(), + other.to_string(), + )], + })?; + let bytes = typst_pdf::pdf(&document, &Default::default()) + .map_err(|d| d.into_iter().collect::>())?; + Ok((bytes, warnings.into_iter().collect())) +} + +/// Compiles Typst source to PDF bytes, with whatever the compiler had to work around. +/// +/// Separate from the command so a test can reach it: a `#[tauri::command]` taking an `AppHandle` +/// needs a running app, and none of the work below wants one. +pub fn compile( + source: String, + images: &[ImageInput], + root_paths: &[String], +) -> Result<(Vec, Vec), String> { + let mut warnings: Vec = Vec::new(); + + let mut binaries: Vec<(&str, Vec)> = Vec::with_capacity(images.len() + 1); + for image in images { + match image_bytes(image, root_paths) { + Ok(bytes) => binaries.push((image.path.as_str(), bytes)), + Err(e) => { + note(&mut warnings, "image", e); + binaries.push((image.path.as_str(), placeholder_for(&image.path))); + } + } + } + binaries.push(("/mitex/mitex.wasm", MITEX_WASM.to_vec())); + + // The two families this app does not bundle, a monospace for code blocks and a math face for + // formulas, come off the system. Neither is shown in the editor, both are large, and shipping a + // mono nobody ever sees on screen so that a code block matches across machines is a trade this + // project has decided against. + let mut fallbacks = crate::fonts::fallbacks(); + let mut fonts: Vec> = FACES.iter().map(|face| face.to_vec()).collect(); + fonts.append(&mut fallbacks.fonts); + + let source = format!("{}{source}", font_preamble(&fallbacks)); + + let failure = match compile_once(&source, MITEX_LIB, &binaries, &fonts) { + Ok((bytes, diagnostics)) => { + for diagnostic in &diagnostics { + if let Some(kind) = kind_for(diagnostic) { + note(&mut warnings, kind, diagnostic.message.to_string()); + } + } + return Ok((bytes, warnings)); + } + Err(failure) => failure, + }; + + // mitex turns LaTeX into Typst by running a wasm module over it, and a formula it cannot parse + // is a hard error rather than a bad-looking equation. One `$\frac{$` a user typed halfway down + // a page of notes would otherwise cost them the entire export, so the second attempt serves a + // stand-in `/mitex/lib.typ` that sets every formula as the source the user actually wrote. The + // page reads worse and the warning says so, which is a trade the user can act on. + if !failure.iter().any(touches_mitex) { + return Err(format_diagnostics(&failure)); + } + let (bytes, diagnostics) = compile_once(&source, MITEX_PLAIN_LIB, &binaries, &fonts) + // The first failure is the one worth reading: the second is whatever the stand-in tripped + // over on the way, and the formula that started it is named in the first. + .map_err(|_| format_diagnostics(&failure))?; + + note( + &mut warnings, + "math", + "a formula could not be typeset, so every formula is shown as the source it was written in" + .to_string(), + ); + for diagnostic in &diagnostics { + if let Some(kind) = kind_for(diagnostic) { + note(&mut warnings, kind, diagnostic.message.to_string()); + } + } + Ok((bytes, warnings)) +} + +/// Compiles Typst source to PDF bytes. Warnings, when there are any, arrive separately on the +/// `pdf-warnings` event, because a compile answers with raw bytes and has nowhere to put a second +/// value. +#[tauri::command(async)] +pub fn pdf_compile( + app: tauri::AppHandle, + source: String, + images: Vec, +) -> Result { + // The same gate `fs::checked` puts in front of every other read, reached the same way it is: + // the open roots out of shared state, then `resolve_in_roots`. The lock is dropped before the + // compile, which is the slowest thing this app does and has no business holding it. + let roots = app.state::(); + let root_paths: Vec = { + let open = roots.0.lock().map_err(|e| e.to_string())?; + open.iter().map(|root| root.path.clone()).collect() + }; + + let (bytes, warnings) = compile(source, &images, &root_paths)?; + if !warnings.is_empty() { + app.emit("pdf-warnings", warnings).ok(); + } + Ok(tauri::ipc::Response::new(bytes)) +} + +/// Writes the finished file to wherever the save panel pointed. +#[tauri::command(async)] +pub fn pdf_write(path: String, bytes: Vec) -> Result<(), String> { + // No root guard here, deliberately, and the next person to read this will assume that is a + // bug. It is not: this path came from the user through a native save panel, so it is the + // user's own choice of destination and not something a document said. The guard exists to stop + // an untrusted document naming a path, and saving somewhere outside every open folder is the + // ordinary case rather than the attack. + // + // What that argument does not cover is a destination that is already one of the user's + // documents. `atomic_write` replaces whatever is there, so a `.md` name typed into the save + // panel is the one way an export can destroy markdown, and this app does not get to be the + // reason a document is gone. The save panel carries a PDF filter and AppKit usually appends + // `.pdf` to a name typed with another extension, but that is the panel being careful rather + // than this command, and the promise is not the panel's to keep. + // + // Only a file that is already there is refused. Writing a document-shaped name that nothing + // holds yet is odd rather than destructive, and the user can see what they typed. + let target = Path::new(&path); + if target.is_file() && matches!(crate::fs::kind_for(target, false), "markdown" | "text") { + let name = target + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()); + return Err(format!("{name} is a document. A PDF cannot be written over it.")); + } + crate::fs::atomic_write(target, &bytes) +} + +fn format_diagnostics(diagnostics: &[SourceDiagnostic]) -> String { + diagnostics + .iter() + .map(|diagnostic| { + let kind = match diagnostic.severity { + Severity::Error => "error", + Severity::Warning => "warning", + }; + let mut message = format!("{kind}: {}", diagnostic.message); + for hint in &diagnostic.hints { + message.push_str(&format!("\n hint: {hint}")); + } + message + }) + .collect::>() + .join("\n") +} diff --git a/src-tauri/src/writingtools.rs b/src-tauri/src/writingtools.rs new file mode 100644 index 0000000..ce00b60 --- /dev/null +++ b/src-tauri/src/writingtools.rs @@ -0,0 +1,146 @@ +// Writing Tools, which is Apple's and not this app's. +// +// There is no API to call. The system puts a "Writing Tools" submenu on the Edit menu of any app +// with an editable text view, and the only way in from here is to find that item on the live +// NSMenu and perform it. What happens next happens in the webview, to the DOM, without asking: +// that is the whole risk in this feature, docs/architecture.md is where the seam is described, and +// src/editor/writing.ts is where the selection that may be handed to one is decided. + +#[cfg(target_os = "macos")] +use std::sync::atomic::{AtomicBool, Ordering}; + +/// What the main thread last saw. Read below by a command that has no handle to hop with. +#[cfg(target_os = "macos")] +static SUBMENU_SEEN: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "macos")] +mod mac { + use objc2::rc::Retained; + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSMenu, NSMenuItem}; + use objc2_foundation::NSArray; + + fn submenu_named(items: &NSArray, title: &str) -> Option> { + for i in 0..items.count() { + let item = items.objectAtIndex(i); + if item.title().to_string() == title { + return item.submenu(); + } + } + None + } + + fn edit_menu(mtm: MainThreadMarker) -> Option> { + let main = NSApplication::sharedApplication(mtm).mainMenu()?; + for i in 0..main.numberOfItems() { + let Some(item) = main.itemAtIndex(i) else { continue }; + let Some(submenu) = item.submenu() else { continue }; + if submenu.title().to_string() == "Edit" || item.title().to_string() == "Edit" { + return Some(submenu); + } + } + None + } + + pub fn writing_tools_menu(mtm: MainThreadMarker) -> Option> { + submenu_named(&edit_menu(mtm)?.itemArray(), "Writing Tools") + } + + /// Whether the submenu is on the Edit menu right now, and false off the main thread, where + /// AppKit may not be asked. + pub fn available() -> bool { + MainThreadMarker::new().is_some_and(|mtm| writing_tools_menu(mtm).is_some()) + } + + /// Fires one row of the submenu by its title, which is English because the frontend has no way + /// to know what this Mac calls it. A row that is not found is an error rather than a no-op: a + /// gesture that does nothing and says nothing is the worst answer available. + pub fn perform(tool: &str) -> Result<(), String> { + let Some(mtm) = MainThreadMarker::new() else { + return Err("Writing Tools has to be performed on the main thread.".into()); + }; + let Some(menu) = writing_tools_menu(mtm) else { + return Err("This Mac has no Writing Tools menu.".into()); + }; + let items = menu.itemArray(); + for i in 0..items.count() { + if items.objectAtIndex(i).title().to_string() == tool { + menu.performActionForItemAtIndex(i as isize); + return Ok(()); + } + } + Err(format!("The Writing Tools menu has no {tool} item.")) + } +} + +/// Called once from lib.rs's `setup`, which is early enough. +/// +/// The sibling app looks for the submenu here and it was worth checking rather than copying, +/// because the submenu is AppKit's and nothing in this process puts it there. Measured on macOS +/// 26.5: at `setup`, before the webview has loaded a page, the Edit menu already carries "Writing +/// Tools" with Proofread and Rewrite on it, so there is nothing to wait for and no window event to +/// hang this off. A Mac without Apple Intelligence has no submenu at any moment, which is the same +/// code path. +/// +/// This deliberately does not put Shift+Option+F and Shift+Option+R on Apple's own rows, which is +/// what the sibling does. AppKit performs a key equivalent by firing the menu item directly, so a +/// chord on the system's row reaches Writing Tools without passing the selection guard in +/// src/editor/writing.ts, and that guard is refusing selections that corrupt the file: a rewrite +/// spanning a link loses its address, one spanning two table cells widens the table. The chords are +/// on this app's own Edit rows instead, in lib.rs, where they route through the command table and +/// meet the guard. Exactly one menu item still owns each chord. +pub fn install(_app: &tauri::AppHandle) { + #[cfg(target_os = "macos")] + SUBMENU_SEEN.store(mac::available(), Ordering::Relaxed); +} + +/// Whether this machine can actually run a Writing Tool. Writing Tools needs macOS 15.1 and Apple +/// Intelligence turned on, and `minimumSystemVersion` for this app is 10.15, so an unavailable menu +/// is the ordinary case and not an error: the frontend turns it into a toast rather than a button +/// that silently does nothing. +/// +/// Answered from the live menu rather than from a version number, because the version is necessary +/// and not sufficient: the feature is off until the user turns Apple Intelligence on, and the +/// submenu is the only thing that knows. This signature carries no `AppHandle` to hop threads with, +/// so off the main thread it answers with what `install` saw at launch instead of guessing. +#[tauri::command] +pub fn writing_available() -> Result { + #[cfg(target_os = "macos")] + { + if mac::available() { + SUBMENU_SEEN.store(true, Ordering::Relaxed); + return Ok(true); + } + Ok(SUBMENU_SEEN.load(Ordering::Relaxed)) + } + #[cfg(not(target_os = "macos"))] + { + Ok(false) + } +} + +/// Fires one item on the system's Writing Tools submenu, by its English title. +#[tauri::command] +pub fn writing_run(app: tauri::AppHandle, tool: String) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use objc2::MainThreadMarker; + // Performed here when this is already the main thread, so a missing row comes back as an + // error the frontend can say out loud. The hop is the fallback and it cannot report: waiting + // on its answer would deadlock exactly when the wait was unnecessary. + if MainThreadMarker::new().is_some() { + return mac::perform(&tool); + } + app.run_on_main_thread(move || { + if let Err(e) = mac::perform(&tool) { + eprintln!("writing tools: {e}"); + } + }) + .map_err(|e| e.to_string()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (app, tool); + Err("Writing Tools is a macOS feature.".into()) + } +} diff --git a/src-tauri/stubs/burn-cuda/Cargo.toml b/src-tauri/stubs/burn-cuda/Cargo.toml new file mode 100644 index 0000000..c0b4a3c --- /dev/null +++ b/src-tauri/stubs/burn-cuda/Cargo.toml @@ -0,0 +1,21 @@ +# Empty stand-in for `burn-cuda`, used via [patch.crates-io] in ../../Cargo.toml. +# harper-core pulls burn-cuda only as an optional, disabled CUDA backend. This stub keeps +# the heavy cubecl + tracel-llvm subtree out of the dependency graph. Never compiled. +# The feature names below must match the ones `burn` references via `burn-cuda?/...` so that +# Cargo's feature validation passes; they are all empty no-ops. +[package] +name = "burn-cuda" +version = "0.19.1" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +[features] +default = [] +std = [] +doc = [] +fusion = [] +autotune = [] +autotune-checks = [] diff --git a/src-tauri/stubs/burn-cuda/src/lib.rs b/src-tauri/stubs/burn-cuda/src/lib.rs new file mode 100644 index 0000000..5aa8e3c --- /dev/null +++ b/src-tauri/stubs/burn-cuda/src/lib.rs @@ -0,0 +1 @@ +// Intentionally empty. See Cargo.toml: this stubs out the disabled `burn-cuda` backend. diff --git a/src-tauri/stubs/cubecl-cpu/Cargo.toml b/src-tauri/stubs/cubecl-cpu/Cargo.toml new file mode 100644 index 0000000..6cc23ab --- /dev/null +++ b/src-tauri/stubs/cubecl-cpu/Cargo.toml @@ -0,0 +1,17 @@ +# Empty stand-in for `cubecl-cpu`, used via [patch.crates-io] in ../../Cargo.toml. +# cubecl-cpu is the LLVM/MLIR JIT CPU runtime pulled (transitively, through burn's disabled GPU +# backends) by harper-core. It drags in the tracel-llvm / tracel-llvm-bundler crates, which build +# and bundle an LLVM toolchain and are by a wide margin the most expensive thing in the graph. +# Replacing it with this empty stub removes that whole subtree. Nothing here is ever compiled +# (GPU backends are off). +[package] +name = "cubecl-cpu" +version = "0.8.1" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +[features] +default = [] diff --git a/src-tauri/stubs/cubecl-cpu/src/lib.rs b/src-tauri/stubs/cubecl-cpu/src/lib.rs new file mode 100644 index 0000000..b3248c3 --- /dev/null +++ b/src-tauri/stubs/cubecl-cpu/src/lib.rs @@ -0,0 +1 @@ +// Intentionally empty. See Cargo.toml: this stubs out the disabled cubecl-cpu JIT runtime. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4da20ad..afaeb36 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -41,7 +41,8 @@ "icons/icon.ico" ], "macOS": { - "minimumSystemVersion": "10.15" + "minimumSystemVersion": "10.15", + "hardenedRuntime": true } } } diff --git a/src-tauri/tests/export_writes_only_the_pdf.rs b/src-tauri/tests/export_writes_only_the_pdf.rs new file mode 100644 index 0000000..5db5d29 --- /dev/null +++ b/src-tauri/tests/export_writes_only_the_pdf.rs @@ -0,0 +1,481 @@ +// An export over a real folder of markdown, asked the same question src-tauri/tests/no_write_on_open.rs +// asks of a save: did anything in the folder move? +// +// src-tauri/tests/pdf.rs already covers what the compiler refuses and what it survives, against a +// TempDir with two files in it. That is the right shape for a question about diagnostics and the +// wrong shape for a question about bytes: a bag of files in a temporary folder cannot say that a +// folder somebody would plausibly have opened comes back from `git status` with no lines in it. So +// this suite runs against the same generated git repository the no-write suite does, built by +// tests/support/notes_repo.rs, and the oracle is the same one: `git status --porcelain`, plus a +// stat and content snapshot of every path under the root so that a rewrite with identical bytes is +// still caught. +// +// The tests share that one folder and `pristine()` resets it, so they hold a lock rather than +// needing `--test-threads=1`. Running the binary under any thread count is correct. +// +// The sharp question is at the bottom, and it is about `pdf_write` rather than about the compiler. +// `pdf_write` skips the open-roots guard on purpose, because its path came from a native save panel +// and is the user's own choice of destination. What that also means is that it will write to +// whatever it is given, and nothing on either side of the boundary checks that the destination is +// not one of the user's own documents. `a_save_panel_pointed_at_a_document_is_refused` is what +// happens then, written down so that it is a fact somebody decided rather than one nobody noticed. + +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use margin_docs_lib::dto::ImageInput; +use margin_docs_lib::pdf::{compile, pdf_write}; +use tempfile::TempDir; + +#[path = "support/notes_repo.rs"] +mod notes_repo; + +// ---------------------------------------------------------------- fixture + +/// One test in the folder at a time. The suite shares a single repository and `pristine()` throws +/// away whatever the last test did to it, so this is what makes the sharing safe under `cargo +/// test`'s default thread count instead of a flag in CI that somebody has to remember. +static FIXTURE: Mutex<()> = Mutex::new(()); + +/// Back to the committed state, with the lock held for as long as the guard lives. +/// +/// A poisoned lock is taken anyway: it means an earlier test panicked, which is a failure that has +/// already been reported, and refusing to run the rest of the suite on top of it turns one red test +/// into a file of them. +fn pristine() -> (MutexGuard<'static, ()>, PathBuf) { + let guard = FIXTURE.lock().unwrap_or_else(|e| e.into_inner()); + let root = notes_repo::path().to_path_buf(); + assert!( + root.join(".git").is_dir(), + "the fixture repo is missing: {}", + root.display() + ); + notes_repo::git(&["reset", "--hard", "-q"]); + notes_repo::git(&["clean", "-fdq"]); + let status = notes_repo::git(&["status", "--porcelain"]); + assert!( + status.is_empty(), + "the fixture repo did not start clean:\n{status}" + ); + (guard, root) +} + +fn git_status() -> String { + notes_repo::git(&["status", "--porcelain"]) +} + +fn quoted(status: &str) -> String { + if status.is_empty() { + " ".to_string() + } else { + status + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") + } +} + +fn assert_clean(label: &str) { + let status = git_status(); + println!(" [{label}] git status --porcelain:\n{}", quoted(&status)); + assert!(status.is_empty(), "{label} left git dirty:\n{status}"); +} + +// ---------------------------------------------------------------- snapshots + +/// Enough of a file to notice a rewrite that put the same bytes back. `git status` cannot see one +/// of those and it is exactly what an exporter tidying up after itself would leave. +#[derive(Clone, PartialEq, Eq, Debug)] +struct Stamp { + kind: &'static str, + len: u64, + mtime: (i64, i64), + ino: u64, + /// Content hash, for everything outside the vendored `node_modules`, where the stat is already + /// conclusive and hashing 13,000 files on every snapshot is not worth the second it costs. + hash: Option, +} + +type Snapshot = BTreeMap; + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Every path under `root`, dotfiles, .git and node_modules included. +fn snapshot(root: &Path) -> Snapshot { + let mut out = Snapshot::new(); + walk(root, root, &mut out); + out +} + +fn walk(root: &Path, dir: &Path, out: &mut Snapshot) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .into_owned(); + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + let kind = if meta.is_dir() { + "dir" + } else if meta.is_symlink() { + "link" + } else { + "file" + }; + let cheap = rel.starts_with("node_modules/") || rel.starts_with(".git/"); + out.insert( + rel, + Stamp { + kind, + len: meta.len(), + mtime: (meta.mtime(), meta.mtime_nsec()), + ino: meta.ino(), + hash: if kind == "file" && !cheap { + fs::read(&path).ok().map(|bytes| fnv1a(&bytes)) + } else { + None + }, + }, + ); + if meta.is_dir() { + walk(root, &path, out); + } + } +} + +/// What moved between two snapshots, ignoring nothing. +fn changes(before: &Snapshot, after: &Snapshot) -> (Vec, Vec, Vec) { + let mut added = Vec::new(); + let mut removed = Vec::new(); + let mut changed = Vec::new(); + for (path, stamp) in after { + match before.get(path) { + None => added.push(path.clone()), + Some(was) if was != stamp => { + changed.push(format!("{path}\n was {was:?}\n now {stamp:?}")) + } + Some(_) => {} + } + } + for path in before.keys() { + if !after.contains_key(path) { + removed.push(path.clone()); + } + } + (added, removed, changed) +} + +fn assert_untouched(label: &str, before: &Snapshot, after: &Snapshot) { + let (added, removed, changed) = changes(before, after); + assert!( + added.is_empty() && removed.is_empty() && changed.is_empty(), + "{label} touched the folder\n added: {added:?}\n removed: {removed:?}\n changed:\n {}", + changed.join("\n ") + ); + println!(" [{label}] {} paths, none touched", before.len()); +} + +/// The same, allowing exactly the paths named to appear and the folders holding them to have been +/// written into. A PDF exported next to the documents is a new file, and a new file is a new mtime +/// on its parent directory; nothing else is allowed to move. +fn assert_only_added(label: &str, before: &Snapshot, after: &Snapshot, expected: &[&str]) { + let (added, removed, changed) = changes(before, after); + assert_eq!(added, expected, "{label} added something unexpected"); + assert!(removed.is_empty(), "{label} removed {removed:?}"); + let parents: Vec = expected + .iter() + .map(|p| match p.rfind('/') { + Some(at) => p[..at].to_string(), + None => String::new(), + }) + .collect(); + let unexpected: Vec<&String> = changed + .iter() + .filter(|entry| { + let path = entry.lines().next().unwrap_or_default(); + !parents.iter().any(|parent| parent == path) + }) + .collect(); + assert!( + unexpected.is_empty(), + "{label} changed more than the folder it wrote into:\n {}", + unexpected + .iter() + .map(|s| s.as_str()) + .collect::>() + .join("\n ") + ); + println!(" [{label}] added {expected:?} and moved nothing else"); +} + +// ---------------------------------------------------------------- the document + +/// The head of what src/export/typst.ts writes: the mitex import that is the contract between the +/// converter and src-tauri/src/pdf.rs, and set rules naming no font, because which faces exist is +/// the backend's business. +const PREAMBLE: &str = r#"#import "/mitex/lib.typ": mitex, mi +#set document(title: "Handbook") +#set page(paper: "a4", margin: 2cm) +#set text(size: 11pt, lang: "en") +"#; + +/// A drawn mermaid diagram as it crosses the boundary: SVG from the webview, with no file behind it. +const DIAGRAM: &[u8] = + br##""##; + +/// The whole export, as the converter would have written it for a document in this folder: a +/// picture that is a real file inside the open root, a drawn diagram that is bytes, a table and a +/// formula. Every one of those is a path the exporter does extra work on, and three of them mean +/// the compiler opening something. +fn document_from(root: &Path) -> (String, Vec) { + let picture = root.join("assets/logo.png"); + assert!(picture.is_file(), "the fixture has an asset to point at"); + + let source = format!( + "{PREAMBLE} += The handbook + +Prose, then a picture that is a file in the open folder. + +#image(\"{}\") + +#figure(image(\"/inline/diagram-1.svg\")) + +#table(columns: 2, [region], [total], [north], [12]) + +Inline #mi(\"a^2 + b^2 = c^2\") and a display one: + +#mitex(\"\\\\frac{{1}}{{2}} \\\\int_0^1 x^2 dx\") +", + picture.display() + ); + + let images = vec![ + ImageInput { + path: picture.to_string_lossy().into_owned(), + data: None, + }, + ImageInput { + path: "/inline/diagram-1.svg".to_string(), + data: Some(STANDARD.encode(DIAGRAM)), + }, + ]; + (source, images) +} + +fn roots_of(root: &Path) -> Vec { + vec![root.to_string_lossy().into_owned()] +} + +// ================================================================ compiling + +#[test] +fn compiling_a_document_out_of_a_real_folder_writes_nothing_in_it() { + let (_lock, root) = pristine(); + let (source, images) = document_from(&root); + + let before = snapshot(&root); + let (bytes, warnings) = + compile(source, &images, &roots_of(&root)).expect("the folder compiles"); + let after = snapshot(&root); + + assert_eq!(&bytes[..4], b"%PDF", "the answer is a PDF"); + // The picture really was read off disk rather than worked around, so the one step of the + // compile that opens a file in the user's folder is a step this test actually took. + let worked_around: Vec<&str> = warnings + .iter() + .filter(|w| w.kind == "image") + .map(|w| w.message.as_str()) + .collect(); + assert!( + worked_around.is_empty(), + "the image in the open folder was read: {worked_around:?}" + ); + + assert_untouched("a compile", &before, &after); + assert_clean("a compile"); +} + +#[test] +fn compiling_the_same_folder_ten_times_over_still_writes_nothing() { + // Once could be a compile that failed early and touched nothing because it did nothing. Ten + // laps, with the fonts loaded and the images opened every time, is the shape of the thing the + // user actually does: export, read it, change a line, export again. + let (_lock, root) = pristine(); + let before = snapshot(&root); + + for lap in 0..10 { + let (source, images) = document_from(&root); + let (bytes, _) = + compile(source, &images, &roots_of(&root)).unwrap_or_else(|e| panic!("lap {lap}: {e}")); + assert_eq!(&bytes[..4], b"%PDF"); + } + + assert_untouched("ten compiles", &before, &snapshot(&root)); + assert_clean("ten compiles"); +} + +// ================================================================ writing + +#[test] +fn exporting_to_a_folder_of_its_own_leaves_the_documents_alone() { + // The ordinary export: the panel points somewhere outside the notes folder, which is where a + // PDF belongs. Nothing in the folder the document came from has any business moving, and the + // whole of `git status` is the evidence. + let (_lock, root) = pristine(); + let (source, images) = document_from(&root); + let elsewhere = TempDir::new().expect("a temp dir"); + let target = elsewhere.path().join("The handbook.pdf"); + + let before = snapshot(&root); + let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles"); + pdf_write(target.to_string_lossy().into_owned(), bytes.clone()).expect("the panel's path"); + let after = snapshot(&root); + + assert_eq!(fs::read(&target).expect("the PDF is there"), bytes); + assert_untouched("an export to another folder", &before, &after); + assert_clean("an export to another folder"); +} + +#[test] +fn exporting_beside_the_documents_adds_the_pdf_and_moves_nothing_else() { + // The other ordinary export, and the one that cannot leave `git status` empty: a PDF written + // next to the document is a new file in the folder and shows up as one. What matters is that + // it is the only line, and that no document was rewritten to put it there. + let (_lock, root) = pristine(); + let (source, images) = document_from(&root); + let target = root.join("docs/guides/setup.pdf"); + + let before = snapshot(&root); + let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles"); + pdf_write(target.to_string_lossy().into_owned(), bytes).expect("a path inside the root"); + let after = snapshot(&root); + + assert_only_added( + "an export into the folder", + &before, + &after, + &["docs/guides/setup.pdf"], + ); + + let status = git_status(); + println!( + "git status --porcelain after an export into the folder:\n{}", + quoted(&status) + ); + assert_eq!( + status.lines().collect::>(), + vec!["?? docs/guides/setup.pdf"], + "the PDF is the only thing git can see" + ); + + // And the atomic write left nothing of its own behind: the temp file it renames through is + // gone, so there is no `.setup.pdf.tmp` for the next `git add -A` to sweep up. + let strays: Vec = fs::read_dir(root.join("docs/guides")) + .expect("the guides folder") + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|name| !name.ends_with(".md") && name != "setup.pdf") + .collect(); + assert!(strays.is_empty(), "the write left {strays:?} behind"); +} + +// ================================================================ the guard that is not there + +#[test] +fn a_save_panel_pointed_at_a_document_is_refused() { + // This test was written the other way up, pinning the overwrite as a fact somebody had decided. + // Nobody had: it was the one way an export could destroy markdown, and it needed only a user who + // reached the save panel and typed a `.md` name into it. It has not been seen because the panel + // carries a PDF filter and AppKit usually appends `.pdf` to a name typed with another extension, + // which is the panel being careful rather than this module, and the promise that the editor + // never writes a file the user did not edit is not the panel's to keep. + // + // So `pdf_write` now refuses a destination that already holds one of the user's documents, and + // this is the same test rewritten to the truth that replaced it. Everything about the rest of + // the decision still stands: the root guard is still off this path on purpose, which is what + // `pdf_write_will_also_write_outside_every_open_folder` below is about. + let (_lock, root) = pristine(); + let (source, images) = document_from(&root); + let document = root.join("docs/design.md"); + let markdown = fs::read(&document).expect("a document to aim at"); + assert!( + markdown.starts_with(b"#") || markdown.len() > 100, + "the fixture document has real markdown in it" + ); + + let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles"); + let answer = pdf_write(document.to_string_lossy().into_owned(), bytes.clone()); + + let message = answer.expect_err("a document is not a place to put a PDF"); + assert!( + message.contains("design.md") && message.contains("document"), + "the refusal names the file and says why: {message}" + ); + assert_eq!( + fs::read(&document).expect("the document is still a file"), + markdown, + "the document is untouched, byte for byte" + ); + + let status = git_status(); + assert_eq!(status, "", "the folder is clean:\n{}", quoted(&status)); + + // A .txt is a document too, and a .pdf that is already there is not: replacing one is what a + // save panel is for, and it has already asked. + let notes = root.join("docs/notes.txt"); + fs::write(¬es, b"a plain text document\n").expect("a text file"); + let again = bytes; + pdf_write(notes.to_string_lossy().into_owned(), again.clone()) + .expect_err("a .txt is a document as much as a .md is"); + + let existing = root.join("docs/already.pdf"); + fs::write(&existing, b"%PDF-1.7 an older export\n").expect("an older PDF"); + pdf_write(existing.to_string_lossy().into_owned(), again.clone()) + .expect("replacing a PDF is what the panel asked about"); + assert_eq!(fs::read(&existing).expect("the newer export"), again); + + let fresh = root.join("docs/never-existed.md"); + pdf_write(fresh.to_string_lossy().into_owned(), again) + .expect("a name nothing holds destroys nothing, however odd it looks"); +} + +#[test] +fn pdf_write_will_also_write_outside_every_open_folder() { + // The half of the same decision that is intended, kept next to the half that is not so the two + // are read together. A PDF belongs on the Desktop or in Downloads far more often than it + // belongs in the notes folder, so `resolve_in_roots` is not on this path and must not be. The + // fix for the test above, if there is one, is not a root guard. + let (_lock, root) = pristine(); + let elsewhere = TempDir::new().expect("a temp dir"); + let target = elsewhere.path().join("report.pdf"); + + pdf_write( + target.to_string_lossy().into_owned(), + b"%PDF-1.7\n".to_vec(), + ) + .expect("somewhere the user never opened"); + + assert_eq!(fs::read(&target).expect("the PDF"), b"%PDF-1.7\n"); + assert_clean("a write outside every root"); + let _ = root; +} diff --git a/src-tauri/tests/grammar.rs b/src-tauri/tests/grammar.rs new file mode 100644 index 0000000..ce07561 --- /dev/null +++ b/src-tauri/tests/grammar.rs @@ -0,0 +1,126 @@ +// What the grammar checker promises the frontend, asserted against the real engine. +// +// Three things, and the first is the one that would be invisible everywhere else. Offsets are in +// characters, and src/editor/proofing.ts adds a ProseMirror position to them without converting +// anything, so an engine that counted bytes would draw its underlines further and further to the +// left of the words they are about for every non-ASCII character earlier in the paragraph. That is +// a bug nobody writing in English would ever see and everybody writing in French or Polish would +// see immediately, and no other test in this repository is in a position to notice it: the Rust +// suite otherwise asserts about files, and the browser suite talks to the fixture in +// src/dev/mockIpc.ts rather than to Harper. +// +// The second is that Harper's own spell rule is off. It is turned off in src-tauri/src/grammar.rs because +// NSSpellChecker already does the spelling and knows the user's own names and languages, and a +// second dictionary underlining "Yoshinari" would be exactly the noise that gets a checker switched +// off for good. Left on, everything would still be green: there would just be spelling issues +// arriving through the grammar command, drawn in the grammar colour, with no Learn item on them. +// +// The third is that the engine survives being asked twice. It is a static built on first use and a +// `LintGroup` that lints through `&mut self`, so a second call is the one that would find a lock +// held or a state left dirty by the first. + +use margin_docs_lib::dto::GrammarIssue; +use margin_docs_lib::grammar::{grammar_available, grammar_check}; + +/// The text a lint is about, sliced the way the frontend slices it: by character. +fn flagged(text: &str, issue: &GrammarIssue) -> String { + text.chars() + .skip(issue.start) + .take(issue.end - issue.start) + .collect() +} + +fn check(text: &str) -> Vec { + grammar_check(text.to_string()).expect("the checker answered") +} + +#[test] +fn a_build_with_harper_in_it_says_so() { + assert!(grammar_available().expect("availability answered")); +} + +#[test] +fn a_repeated_word_is_found_and_can_be_corrected() { + let issues = check("I put the the book down."); + let repeat = issues + .iter() + .find(|issue| flagged("I put the the book down.", issue).contains("the the")) + .expect("the repeated word was found"); + + assert!(!repeat.kind.is_empty(), "a lint carries the rule's category"); + assert!(!repeat.message.is_empty(), "and something to show the reader"); + assert!( + repeat.suggestions.iter().any(|s| s.trim() == "the"), + "with the correction on it: {:?}", + repeat.suggestions + ); +} + +#[test] +fn offsets_are_characters_and_not_bytes() { + // Every character before the mistake is three bytes in UTF-8, so a checker counting bytes would + // report a span three times too far along and the assertion below would slice the wrong words. + let text = "Zażółć gęślą jaźń, and then I put the the book down."; + let issues = check(text); + let repeat = issues + .iter() + .find(|issue| flagged(text, issue).contains("the the")) + .expect("the repeated word was found after a run of non-ASCII text"); + + assert_eq!(flagged(text, repeat), "the the"); + assert!( + repeat.end <= text.chars().count(), + "and it ends inside the run it was told about" + ); +} + +#[test] +fn spelling_is_left_to_the_system_checker() { + // Not a word in any dictionary, and Harper's curated one included. Nothing may come back about + // it, because the only rule that would have is the one src-tauri/src/grammar.rs turns off. + let text = "The flurbulent maglifter needs oiling."; + for issue in check(text) { + assert_ne!( + issue.kind, "Spelling", + "the grammar checker reported a spelling issue: {issue:?}" + ); + } +} + +#[test] +fn nothing_to_say_about_nothing() { + assert!(check("").is_empty()); +} + +/// Harper flags two spaces between sentences as "French spaces", and src-tauri/src/grammar.rs drops every +/// lint whose whole span is whitespace for the reasons written there: an underline nobody can see +/// or click on, and, when the spaces are the ones src/editor/proofing.ts writes in place of an +/// inline code span, an underline over the one thing that file went out of its way not to send. +/// +/// These two inputs are the ones that reach the filter, so taking the filter out fails this test +/// rather than leaving it green. What it cannot see is a future Harper that stops raising the lint +/// at all, which would leave the guard unreached and this file none the wiser. +#[test] +fn a_lint_about_nothing_but_spaces_is_not_reported() { + assert!( + check("This is fine. Two spaces there.").is_empty(), + "the two spaces between the sentences were reported" + ); + // The shape src/editor/proofing.ts produces from a blanked inline code span. + assert!( + check("The value is nine.").is_empty(), + "a blanked code span was reported as a run of spaces" + ); +} + +#[test] +fn the_engine_is_kept_and_answers_the_same_way_twice() { + let text = "I put the the book down."; + let first = check(text); + let second = check(text); + assert_eq!(first.len(), second.len()); + assert_eq!( + first.first().map(|i| (i.start, i.end)), + second.first().map(|i| (i.start, i.end)) + ); +} diff --git a/src-tauri/tests/pdf.rs b/src-tauri/tests/pdf.rs new file mode 100644 index 0000000..cd6d413 --- /dev/null +++ b/src-tauri/tests/pdf.rs @@ -0,0 +1,316 @@ +// The exporter compiles a document nobody has checked, so the tests here are about what it refuses +// and what it survives rather than about the typesetting: a formula reaches mitex instead of the +// page as source, an image outside every open folder is never read, and neither a broken link nor a +// formula the converter choked on costs the user the whole PDF. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use margin_docs_lib::dto::ImageInput; +use margin_docs_lib::pdf::compile; +use tempfile::TempDir; + +/// A real 4x4 PNG, so an image that is meant to be read is one Typst can actually decode, and one +/// the placeholder cannot be mistaken for. +const PNG: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGO8I2LDAANMcBZeDgA8PAE0qLfS9QAAAABJRU5ErkJggg=="; + +/// The shape of what src/export/typst.ts puts at the top of a document with a formula in it: the +/// mitex import, which is the contract between the converter and this module, and set rules that +/// name neither the monospace nor the maths face, because which of those exist is the backend's +/// business and a family named hopefully is a warning per export about fonts nobody chose. A +/// document set rule after the preamble pdf.rs prepends is part of what these fixtures prove. +const PREAMBLE: &str = r#"#import "/mitex/lib.typ": mitex, mi +#set document(title: "Fixture") +#set page(paper: "a4", margin: 2cm) +#set text(size: 11pt, lang: "en") +"#; + +fn png_bytes() -> Vec { + STANDARD.decode(PNG).unwrap() +} + +fn root() -> (TempDir, Vec) { + let dir = TempDir::new().expect("a temp dir"); + let paths = vec![dir.path().to_string_lossy().into_owned()]; + (dir, paths) +} + +fn kinds<'a>(warnings: &'a [margin_docs_lib::dto::PdfWarning], kind: &str) -> Vec<&'a str> { + warnings + .iter() + .filter(|w| w.kind == kind) + .map(|w| w.message.as_str()) + .collect() +} + +#[test] +fn a_document_with_an_image_and_a_formula_compiles() { + let (dir, roots) = root(); + let photo = dir.path().join("photo.png"); + std::fs::write(&photo, png_bytes()).unwrap(); + + let source = format!( + "{PREAMBLE} += Fixture + +Inline #mi(\"a^2 + b^2 = c^2\") in a sentence. + +#mitex(\"\\\\frac{{1}}{{2}} \\\\int_0^1 x^2 dx\") + +#image(\"{}\") + +Diagram: #image(\"diagram.svg\") +", + photo.display() + ); + + let images = vec![ + ImageInput { + path: photo.to_string_lossy().into_owned(), + data: None, + }, + // How a mermaid diagram arrives: rendered to SVG in the webview, with no file behind it. + ImageInput { + path: "diagram.svg".to_string(), + data: Some(STANDARD.encode( + br##""##, + )), + }, + ]; + + let (bytes, warnings) = compile(source, &images, &roots).expect("the fixture compiles"); + + assert_eq!(&bytes[..4], b"%PDF", "the answer is a PDF"); + assert!( + kinds(&warnings, "image").is_empty(), + "no image was worked around: {:?}", + kinds(&warnings, "image") + ); + assert!( + kinds(&warnings, "math").is_empty(), + "no formula was worked around: {:?}", + kinds(&warnings, "math") + ); +} + +#[test] +fn a_formula_typesets_through_mitex_rather_than_falling_back_to_source() { + let (_dir, roots) = root(); + + // Only the wasm plugin can turn `\frac{a}{b}` into Typst's own `frac(a, b )`, so asserting on + // what came back out of it proves the whole path resolved: the import, the relative imports + // underneath it, the specs and the plugin. An `assert` inside the document makes a wrong answer + // a compile error rather than a difference in bytes nobody would notice. + let source = format!( + "{PREAMBLE}#import \"/mitex/lib.typ\": mitex-convert +#assert.eq(mitex-convert(\"\\\\frac{{a}}{{b}}\"), \"frac(a ,b )\") +#mi(\"\\\\alpha + \\\\beta\") +" + ); + + let (bytes, warnings) = compile(source, &[], &roots).expect("mitex loads and converts"); + assert_eq!(&bytes[..4], b"%PDF"); + assert!( + kinds(&warnings, "math").is_empty(), + "the formula typeset without a workaround: {:?}", + kinds(&warnings, "math") + ); +} + +#[test] +fn the_mitex_files_are_the_only_thing_the_compiler_can_import() { + let (_dir, roots) = root(); + + let source = format!("{PREAMBLE}#import \"/etc/passwd\": *\n"); + let error = compile(source, &[], &roots).expect_err("nothing outside the vendored files loads"); + assert!( + error.contains("file not found"), + "the compiler has no filesystem: {error}" + ); +} + +#[test] +fn an_image_outside_every_open_folder_is_never_read() { + let (dir, roots) = root(); + // Somewhere the user did not open, which is what `![](../../../.ssh/id_rsa)` resolves to. + let elsewhere = TempDir::new().expect("a temp dir"); + let secret = elsewhere.path().join("id_rsa"); + std::fs::write(&secret, "PRIVATE KEY").unwrap(); + let _ = dir; + + let source = format!( + "{PREAMBLE}#image(\"{}\")\n", + secret.display() + ); + let images = vec![ImageInput { + path: secret.to_string_lossy().into_owned(), + data: None, + }]; + + let (bytes, warnings) = compile(source, &images, &roots).expect("the export still happens"); + assert_eq!(&bytes[..4], b"%PDF"); + + let refused = kinds(&warnings, "image"); + assert_eq!(refused.len(), 1, "one image was worked around: {refused:?}"); + assert!( + refused[0].contains("outside every open folder"), + "the root guard is what refused it: {}", + refused[0] + ); + assert!( + !String::from_utf8_lossy(&bytes).contains("PRIVATE KEY"), + "nothing from the file reached the page" + ); +} + +#[test] +fn one_broken_image_does_not_cost_the_whole_export() { + let (dir, roots) = root(); + let missing = dir.path().join("gone.jpg"); + + let source = format!( + "{PREAMBLE}#image(\"{0}\")\n\n#image(\"{0}\")\n", + missing.display() + ); + let images = vec![ + ImageInput { + path: missing.to_string_lossy().into_owned(), + data: None, + }, + ImageInput { + path: missing.to_string_lossy().into_owned(), + data: None, + }, + ]; + + let (bytes, warnings) = compile(source, &images, &roots).expect("the export still happens"); + assert_eq!(&bytes[..4], b"%PDF"); + + // Two broken links to one file are one problem with a number on it, not two toasts. + let counted: Vec = warnings + .iter() + .filter(|w| w.kind == "image") + .map(|w| w.count) + .collect(); + assert_eq!(counted, vec![2]); +} + +#[test] +fn a_formula_nothing_can_typeset_does_not_cost_the_export() { + let (_dir, roots) = root(); + + // Unbalanced braces, which is what somebody halfway through typing a formula has. mitex cannot + // parse it and says so by stopping the compile, and the whole page of notes would go with it. + let source = format!("{PREAMBLE}#mi(\"\\\\frac{{\")\n"); + + let (bytes, warnings) = compile(source, &[], &roots).expect("the export still happens"); + assert_eq!(&bytes[..4], b"%PDF"); + assert_eq!( + kinds(&warnings, "math"), + vec!["a formula could not be typeset, so every formula is shown as the source it was written in"] + ); +} + +#[test] +fn an_ordinary_document_says_nothing_at_all() { + let (_dir, roots) = root(); + + // Every construct that has ever made this module warn about its own furniture rather than about + // the document: the bundled variable fonts, the font families the preamble names, and mitex's + // own deprecations. A user who wrote none of that should see no toast. + let source = format!( + "{PREAMBLE} += Heading + +A paragraph with `inline code` and #mi(\"a^2 + b^2\") in it. + +```rust +fn main() {{}} +``` +" + ); + + let (bytes, warnings) = compile(source, &[], &roots).expect("the fixture compiles"); + assert_eq!(&bytes[..4], b"%PDF"); + assert!(warnings.is_empty(), "nothing to say: {warnings:?}"); +} + +#[test] +fn a_broken_link_is_worked_around_in_every_format_the_editor_writes() { + let (dir, roots) = root(); + + // Typst decides how to decode an image from the extension and not from the bytes, so the + // stand-in for a file that could not be read has to be a real image of the format the link + // claimed. A png in place of a jpg is a decode error, which is the failure being avoided. + for extension in ["png", "jpg", "jpeg", "gif", "webp", "svg", "heic"] { + let missing = dir.path().join(format!("gone.{extension}")); + let source = format!("{PREAMBLE}#image(\"{}\")\n", missing.display()); + let images = vec![ImageInput { + path: missing.to_string_lossy().into_owned(), + data: None, + }]; + + let (bytes, warnings) = compile(source, &images, &roots) + .unwrap_or_else(|e| panic!("a missing .{extension} still exports: {e}")); + assert_eq!(&bytes[..4], b"%PDF"); + assert_eq!(kinds(&warnings, "image").len(), 1); + } +} + +/// mitex's `\hspace`, `\vspace` and `\raisebox` handlers used to hand the text between the braces +/// to Typst's `eval`, which ran it as code. The text is whatever the author typed, so a markdown +/// file was a program, and the compiler runs in this process rather than beside it: the payload +/// vendor/mitex/PATCHES.md names took nine gigabytes and the app with it. +/// +/// That payload is deliberately not the fixture here. A test that brings down the machine when it +/// fails is not a test anybody can run, and the property underneath it is smaller and exact: a +/// length literal is evaluated and nothing else is. `2cm*2` is the whole difference, because it is +/// arithmetic rather than a literal, and under `eval` it was four centimetres. +/// +/// Proved by what lands on the page rather than by reading the guard, which needs one control: the +/// same source twice is the same bytes, so two documents differing only in a spacing command and +/// differing in bytes is that spacing command doing something. +#[test] +fn a_spacing_command_takes_a_length_and_never_an_expression() { + let (_dir, roots) = root(); + let page = |length: &str| { + let source = format!("{PREAMBLE}x#mi(\"a\\\\hspace{{{length}}}b\")x\n"); + compile(source, &[], &roots).expect("a spacing command typesets").0 + }; + + assert_eq!(page("0pt"), page("0pt"), "the compiler is deterministic"); + assert_ne!(page("0pt"), page("4cm"), "4cm moved nothing on the page"); + assert_eq!(page("0pt"), page("2cm*2"), "an expression reached eval"); +} + +/// Every weight src/export/typst.ts asks for has to be a face of its own, or the hierarchy the +/// author sees on screen is not in the PDF. +/// +/// This is what the static instances in src-tauri/fonts are for. Typst does not lay out a variable +/// axis: it takes the default instance, and every weight comes out at 400. There is no way to read a +/// weight back out of a PDF here, so the assertion is that the same word at two weights is two +/// different documents, which stops being true the moment two weights resolve to one face. +#[test] +fn every_weight_the_converter_asks_for_has_a_face_of_its_own() { + let (_dir, roots) = root(); + let page = |markup: &str| { + let source = format!("{PREAMBLE}{markup}\n"); + compile(source, &[], &roots).expect("a weight typesets").0 + }; + + let cuts = [ + "#text(weight: 400)[Margin]", + "#text(weight: 600)[Margin]", + "#text(weight: 700)[Margin]", + "#text(weight: 400, style: \"italic\")[Margin]", + "#text(weight: 700, style: \"italic\")[Margin]", + "#text(font: \"Hanken Grotesk\", weight: 400)[Margin]", + "#text(font: \"Hanken Grotesk\", weight: 700)[Margin]", + ]; + + for (i, one) in cuts.iter().enumerate() { + for two in &cuts[i + 1..] { + assert_ne!(page(one), page(two), "{one} and {two} are the same face"); + } + } +} diff --git a/src-tauri/vendor/mitex/LICENSE b/src-tauri/vendor/mitex/LICENSE new file mode 100644 index 0000000..a7e77cb --- /dev/null +++ b/src-tauri/vendor/mitex/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/src-tauri/vendor/mitex/PATCHES.md b/src-tauri/vendor/mitex/PATCHES.md new file mode 100644 index 0000000..d96b36d --- /dev/null +++ b/src-tauri/vendor/mitex/PATCHES.md @@ -0,0 +1,20 @@ +# Local changes to mitex 0.2.5 + +This copy is not stock. mitex is vendored rather than fetched through Typst's package system so +that an export needs no network, which also means an update is a manual copy and a patch applied +here is a patch that can be silently lost. Anyone replacing this directory has to re-apply what is +listed below, or re-check that it is no longer needed. + +Upstream is https://github.com/mitex-rs/mitex, Apache-2.0, and LICENSE beside this file is theirs. + +## specs/latex/standard.typ: `\hspace`, `\vspace` and `\raisebox` no longer evaluate their argument + +Those three handlers passed the text between the braces to `eval`, which runs it as Typst code. +The text is whatever the author typed, so a markdown file containing +`$\hspace{range(999999999).len()*1pt}$` is a program rather than a formula. Typst is hermetic, so +this is not a way to read a file or reach the network, but the compiler runs inside the editor's +own process: measured, that document took nine gigabytes resident before the process died, and it +would have taken any unsaved buffer with it. + +The three handlers now go through `mitex-safe-length`, which evaluates the argument only when it is +a plain length literal and answers `0pt` otherwise. `src-tauri/tests/pdf.rs` pins both halves. diff --git a/src-tauri/vendor/mitex/lib.typ b/src-tauri/vendor/mitex/lib.typ new file mode 100644 index 0000000..3e33673 --- /dev/null +++ b/src-tauri/vendor/mitex/lib.typ @@ -0,0 +1 @@ +#import "mitex.typ": mitex-wasm, mitex-convert, mitex-scope, mitex, mitext, mimath, mi \ No newline at end of file diff --git a/src-tauri/vendor/mitex/mitex.typ b/src-tauri/vendor/mitex/mitex.typ new file mode 100644 index 0000000..d22d04d --- /dev/null +++ b/src-tauri/vendor/mitex/mitex.typ @@ -0,0 +1,45 @@ +#import "specs/mod.typ": mitex-scope +#let mitex-wasm = plugin("./mitex.wasm") + +#let get-elem-text(it) = { + { + if type(it) == str { + it + } else if type(it) == content and it.has("text") { + it.text + } else { + panic("Unsupported type: " + str(type(it))) + } + } +} + +#let mitex-convert(it, mode: "math", spec: bytes(())) = { + if mode == "math" { + str(mitex-wasm.convert_math(bytes(get-elem-text(it)), spec)) + } else { + str(mitex-wasm.convert_text(bytes(get-elem-text(it)), spec)) + } +} + +// Math Mode +#let mimath(it, block: true, ..args) = { + let res = mitex-convert(mode: "math", it) + let eval-res = eval("$" + res + "$", scope: mitex-scope) + math.equation(block: block, eval-res, ..args) +} + +// Text Mode +#let mitext(it) = { + let res = mitex-convert(mode: "text", it) + eval(res, mode: "markup", scope: mitex-scope) +} + +#let mitex(it, mode: "math", ..args) = { + if mode == "math" { + mimath(it, ..args) + } else { + mitext(it, ..args) + } +} + +#let mi = mimath.with(block: false) diff --git a/src-tauri/vendor/mitex/mitex.wasm b/src-tauri/vendor/mitex/mitex.wasm new file mode 100644 index 0000000..60bc93c Binary files /dev/null and b/src-tauri/vendor/mitex/mitex.wasm differ diff --git a/src-tauri/vendor/mitex/specs/latex/standard.typ b/src-tauri/vendor/mitex/specs/latex/standard.typ new file mode 100644 index 0000000..79afc8d --- /dev/null +++ b/src-tauri/vendor/mitex/specs/latex/standard.typ @@ -0,0 +1,1170 @@ +#import "../prelude.typ": * + +// 0. Some useful internal variables or functions +#let mitex-color-map = ( + "red": rgb(255, 0, 0), + "green": rgb(0, 255, 0), + "blue": rgb(0, 0, 255), + "cyan": rgb(0, 255, 255), + "magenta": rgb(255, 0, 255), + "yellow": rgb(255, 255, 0), + "black": rgb(0, 0, 0), + "white": rgb(255, 255, 255), + "gray": rgb(128, 128, 128), + "lightgray": rgb(192, 192, 192), + "darkgray": rgb(64, 64, 64), + "brown": rgb(165, 42, 42), + "orange": rgb(255, 165, 0), + "pink": rgb(255, 182, 193), + "purple": rgb(128, 0, 128), + "teal": rgb(0, 128, 128), + "olive": rgb(128, 128, 0), +) +#let get-tex-str-from-arr(arr) = arr.filter(it => it != [ ] and it != [#math.zws]).map(it => it.text).sum() +#let get-tex-str(tex) = if tex.has("children") { + get-tex-str-from-arr(tex.children) +} else { + tex.text +} + +// PATCHED, and not by mitex. See ../../../PATCHES.md. +// +// The three handlers below took the contents of \hspace{}, \vspace{} and \raisebox{} and passed +// them straight to `eval`, which runs them as Typst code. That content is whatever the author typed +// between the braces, so a plain markdown file holding \hspace{range(999999999).len()*1pt} is a +// program, and the compiler runs inside the editor's own process rather than in a sandbox beside +// it: the measured result was nine gigabytes resident and a dead app, taking any unsaved buffer +// with it. Nothing else in the file evaluates document text. +// +// A length is all any of the three can use, so a length is all this accepts. Anything else is zero +// rather than an error, because a document that will not export is a worse answer than a document +// whose spacing command was ignored, and the author can see the second one on the page. +#let mitex-safe-length(raw) = { + if type(raw) != str { + 0pt + } else { + let text = raw.trim() + if text.match(regex("^[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)(pt|mm|cm|in|em)$")) != none { + eval(text) + } else { + 0pt + } + } +} +#let get-tex-color-from-arr(arr) = { + mitex-color-map.at(lower(get-tex-str-from-arr(arr)), default: none) +} +#let get-tex-color(texcolor) = if texcolor.has("children") { + get-tex-color-from-arr(texcolor.children) +} else { + texcolor.text +} + +// 1. functions created to make it easier to define a spec +#let operatornamewithlimits(it) = math.op(limits: true, math.upright(it)) +#let arrow-handle(arrow-sym) = define-cmd(1, handle: it => $limits(stretch(#arrow-sym)^#it)$) +#let _greedy-handle(fn) = (..args) => $fn(#args.pos().sum())$ +#let greedy-handle(alias, fn) = define-greedy-cmd(alias, handle: _greedy-handle(fn)) +#let limits-handle(alias, wrap) = define-cmd(1, alias: alias, handle: it => math.limits(wrap(it))) +#let matrix-handle(delim: none, handle: none) = define-env( + none, + kind: "is-matrix", + alias: none, + handle: math.mat.with(delim: delim), +) +#let call-or-ignore(fn) = (..args) => if args.pos().len() > 0 { + fn(..args) +} else { + math.zws +} +#let ignore-me = (..args) => { } +#let ignore-sym = define-sym("") + +// 2. Standard package definitions, generate specs and scopes, +// for parser/convert and typst respectively +#let ( + spec, + scope, +) = process-spec(( + // Text mode + section: define-cmd(1, alias: "#heading(level: 1)"), + subsection: define-cmd(1, alias: "#heading(level: 2)"), + subsubsection: define-cmd(1, alias: "#heading(level: 3)"), + footnote: define-cmd(1, alias: "#footnote"), + cite: define-cmd(1, alias: "#mitexcite", handle: it => cite(label(get-tex-str(it)))), + emph: define-cmd(1, alias: "#emph"), + label: define-cmd(1, alias: "mitexlabel", handle: ignore-me), + tag: define-cmd(1, alias: "mitexlabel", handle: ignore-me), + ref: define-cmd(1, alias: "#mitexref", handle: it => ref(label(get-tex-str(it)))), + eqref: define-cmd(1, alias: "#mitexref"), + item: ignore-sym, + // environments for text mode + itemize: define-env(none, kind: "is-itemize"), + enumerate: define-env(none, kind: "is-enumerate"), + quote: define-env(none, alias: "quote(block: true)"), + abstract: define-env(none, alias: "quote(block: true)"), + figure: define-glob-env("{,b}", kind: "is-figure", alias: "figure"), + table: define-glob-env("{,b}", kind: "is-figure", alias: "figure"), + tabular: define-env(1, kind: "is-table", alias: "table"), + // commands for figure + centering: ignore-sym, + textwidth: sym, + caption: define-cmd(1, alias: "mitexcaption", handle: ignore-me), + includegraphics: define-glob-cmd("{,b}t", "#miteximage", handle: ignore-me), + // commands for tabular + toprule: define-glob-cmd("{,b}", "toprule"), + midrule: define-glob-cmd("{,b}", "midrule"), + bottomrule: define-glob-cmd("{,b}", "bottomrule"), + hline: ignore-sym, + vline: ignore-sym, + // Spaces: \! \, \> \: \; \ \quad \qquad + "!": define-sym("negthinspace", sym: h(-(3/18) * 1em)), + negthinspace: of-sym(h(-(3/18) * 1em)), + negthinmedspace: of-sym(h(-(3/18) * 1em)), + negmedspace: of-sym(h(-(4/18) * 1em)), + negthickspace: of-sym(h(-(5/18) * 1em)), + ",": define-sym("thin"), + thinspace: define-sym("thin"), + ">": define-sym("med"), + ":": define-sym("med"), + medspace: define-sym("med"), + ";": define-sym("thick"), + "": define-sym("thick"), + thickspace: define-sym("thick"), + enspace: of-sym(h((1/2) * 1em)), + nobreakspace: define-sym("space.nobreak"), + space: sym, + quad: sym, + qquad: define-sym("wide"), + phantom: define-cmd(1, handle: hide), + hphantom: define-cmd(1, handle: it => box(height: 0pt, hide(it))), + vphantom: define-cmd(1, handle: it => box(width: 0pt, hide(it))), + // Escape symbols + "_": define-sym("\\_"), + "^": define-sym("hat"), + "*": define-sym(""), + "|": define-sym("||"), + "&": define-sym("amp"), + "#": define-sym("hash"), + "%": define-sym("%"), + "-": define-sym("-"), + "+": define-sym("+"), + "$": define-sym("dollar"), + "{": define-sym("\\{"), + "}": define-sym("\\}"), + vert: define-sym("|"), + lvert: of-sym(math.class("opening", "|")), + rvert: of-sym(math.class("closing", "|")), + Vert: define-sym("||"), + lVert: of-sym(math.class("opening", "||")), + rVert: of-sym(math.class("closing", "||")), + lparen: define-sym("paren.l"), + rparen: define-sym("paren.r"), + lceil: define-sym("⌈"), + rceil: define-sym("⌉"), + lfloor: define-sym("⌊"), + rfloor: define-sym("⌋"), + // Sizes and styles + displaystyle: greedy-handle("mitexdisplay", math.display), + textstyle: greedy-handle("mitexinline", math.inline), + scriptstyle: greedy-handle("mitexscript", math.script), + scriptscriptstyle: greedy-handle("mitexsscript", math.sscript), + bf: greedy-handle("mitexbold", it => math.bold(math.upright(it))), + rm: greedy-handle("mitexupright", math.upright), + it: greedy-handle("mitexitalic", math.italic), + sf: greedy-handle("mitexsans", math.sans), + frak: greedy-handle("mitexfrak", math.frak), + tt: greedy-handle("mitexmono", math.mono), + cal: greedy-handle("mitexcal", math.cal), + bold: define-cmd(1, alias: "bold"), + mathbf: define-cmd(1, alias: "mitexmathbf", handle: it => math.bold(math.upright(it))), + bm: define-cmd(1, alias: "bold"), + boldsymbol: define-cmd(1, alias: "bold"), + pmb: define-cmd(1, alias: "bold"), + mathrm: define-cmd(1, alias: "upright"), + mathit: define-cmd(1, alias: "italic"), + mathnormal: define-cmd(1, alias: "italic"), + mathsf: define-cmd(1, alias: "sans"), + mathfrak: define-cmd(1, alias: "frak"), + mathtt: define-cmd(1, alias: "mono"), + Bbb: define-cmd(1, alias: "bb"), + mathbb: define-cmd(1, alias: "bb"), + mathcal: define-cmd(1, alias: "cal"), + mathbin: define-cmd(1, handle: it => math.class("binary", it)), + mathclose: define-cmd(1, handle: it => math.class("closing", it)), + mathinner: define-cmd(1, handle: it => math.class("fence", it)), + mathop: define-cmd(1, handle: it => math.class("unary", it)), + mathopen: define-cmd(1, handle: it => math.class("opening", it)), + mathord: define-cmd(1, handle: it => math.class("normal", it)), + mathpunct: define-cmd(1, handle: it => math.class("punctuation", it)), + mathrel: define-cmd(1, handle: it => math.class("relation", it)), + big: define-cmd(1, handle: it => math.lr(size: 1.2em, it)), + Big: define-cmd(1, handle: it => math.lr(size: 1.8em, it)), + bigg: define-cmd(1, handle: it => math.lr(size: 2.4em, it)), + Bigg: define-cmd(1, handle: it => math.lr(size: 3em, it)), + bigl: define-cmd(1, alias: "big"), + Bigl: define-cmd(1, alias: "Big"), + biggl: define-cmd(1, alias: "bigg"), + Biggl: define-cmd(1, alias: "Bigg"), + bigm: define-cmd(1, alias: "big"), + Bigm: define-cmd(1, alias: "Big"), + biggm: define-cmd(1, alias: "bigg"), + Biggm: define-cmd(1, alias: "Bigg"), + bigr: define-cmd(1, alias: "big"), + Bigr: define-cmd(1, alias: "Big"), + biggr: define-cmd(1, alias: "bigg"), + Biggr: define-cmd(1, alias: "Bigg"), + // todo: size, especially multi-line, ignore it for now. + Huge: ignore-sym, + normalsize: ignore-sym, + huge: ignore-sym, + small: ignore-sym, + footnotesize: ignore-sym, + Large: ignore-sym, + LARGE: ignore-sym, + scriptsize: ignore-sym, + large: ignore-sym, + tiny: ignore-sym, + // Colors + color: define-greedy-cmd("mitexcolor", handle: (texcolor, ..args) => { + let color = get-tex-color(texcolor) + if color != none { + text(fill: color, args.pos().sum()) + } else { + args.pos().sum() + } + }), + textcolor: define-cmd(2, alias: "colortext", handle: (texcolor, body) => { + let color = get-tex-color(texcolor) + if color != none { + text(fill: get-tex-color(texcolor), body) + } else { + body + } + }), + colorbox: define-cmd(2, handle: (texcolor, body) => { + let color = get-tex-color(texcolor) + if color != none { + box(fill: get-tex-color(texcolor), $body$) + } else { + body + } + }), + // Limits + limits: left1-op("limits"), + nolimits: left1-op("scripts"), + // Commands + frac: define-cmd(2, handle: (num, den) => $(num)/(den)$), + // todo: cfrac, dfrac are same? + cfrac: define-cmd(2, handle: (num, den) => $display((num)/(den))$), + dfrac: define-cmd(2, handle: (num, den) => $display((num)/(den))$), + tfrac: define-cmd(2, handle: (num, den) => $inline((num)/(den))$), + binom: define-cmd(2), + dbinom: define-cmd(2, handle: (n, k) => $display(binom(#n, #k))$), + tbinom: define-cmd(2, handle: (n, k) => $inline(binom(#n, #k))$), + stackrel: define-cmd(2, handle: (sup, base) => $limits(base)^(sup)$), + substack: define-cmd(1, handle: it => it), + overset: define-cmd(2, handle: (sup, base) => $limits(base)^(sup)$), + underset: define-cmd(2, handle: (sub, base) => $limits(base)_(sub)$), + // Accents + "not": define-cmd(1, alias: "mitexnot", handle: it => math.cancel(angle: 20deg, it)), + cancel: define-cmd(1), + xcancel: define-cmd(1, handle: math.cancel), + bcancel: define-cmd(1, handle: math.cancel.with(inverted: true)), + sout: define-cmd(1, handle: math.cancel.with(angle: 90deg)), + grave: define-cmd(1, alias: "grave"), + acute: define-cmd(1, alias: "acute"), + hat: define-cmd(1, alias: "hat"), + widehat: define-cmd(1, alias: "hat"), + tilde: define-cmd(1, alias: "tilde"), + widetilde: define-cmd(1, alias: "tilde"), + bar: define-cmd(1, alias: "macron"), + breve: define-cmd(1, alias: "breve"), + dot: define-cmd(1, alias: "dot"), + ddot: define-cmd(1, alias: "dot.double"), + dddot: define-cmd(1, alias: "dot.triple"), + ddddot: define-cmd(1, alias: "dot.quad"), + H: define-cmd(1, alias: "acute.double"), + check: define-cmd(1, alias: "caron"), + widecheck: define-cmd(1, alias: "caron"), + u: define-cmd(1, alias: "breve"), + v: define-cmd(1, alias: "caron"), + r: define-cmd(1, alias: "circle"), + vec: define-cmd(1, alias: "arrow"), + overrightarrow: define-cmd(1, alias: "arrow"), + overleftarrow: define-cmd(1, alias: "arrow.l"), + overline: cmd1, + underline: cmd1, + overbrace: limits-handle("mitexoverbrace", math.overbrace), + underbrace: limits-handle("mitexunderbrace", math.underbrace), + overbracket: limits-handle("mitexoverbracket", math.overbracket), + underbracket: limits-handle("mitexunderbracket", math.underbracket), + boxed: define-cmd(1, handle: it => box(stroke: 0.5pt, inset: 6pt, $it$)), + // Greeks + alpha: sym, + beta: sym, + gamma: sym, + delta: sym, + epsilon: define-sym("epsilon.alt"), + zeta: sym, + eta: sym, + theta: sym, + iota: sym, + kappa: sym, + lambda: sym, + mu: sym, + nu: sym, + xi: sym, + omicron: sym, + pi: sym, + rho: sym, + sigma: sym, + tau: sym, + upsilon: sym, + phi: define-sym("phi.alt"), + chi: sym, + psi: sym, + omega: sym, + Alpha: sym, + Beta: sym, + Gamma: sym, + Delta: sym, + Epsilon: sym, + Zeta: sym, + Eta: sym, + Theta: sym, + Iota: sym, + Kappa: sym, + Lambda: sym, + Mu: sym, + Nu: sym, + Xi: sym, + Omicron: sym, + Pi: sym, + Rho: sym, + Sigma: sym, + Tau: sym, + Upsilon: sym, + Phi: sym, + Chi: sym, + Psi: sym, + Omega: sym, + varbeta: define-sym("beta.alt"), + varepsilon: define-sym("epsilon"), + varkappa: define-sym("kappa.alt"), + varphi: define-sym("phi"), + varpi: define-sym("pi.alt"), + varrho: define-sym("rho.alt"), + varsigma: define-sym("sigma.alt"), + vartheta: define-sym("theta.alt"), + ell: sym, + // Function symbols + sin: sym, + cos: sym, + tan: sym, + cot: sym, + sec: sym, + csc: sym, + arcsin: sym, + arccos: sym, + arctan: sym, + sinh: sym, + cosh: sym, + tanh: sym, + coth: sym, + ln: sym, + log: sym, + lg: sym, + lim: sym, + limsup: sym, + liminf: sym, + hom: sym, + det: sym, + exp: sym, + deg: sym, + gcd: sym, + lcm: sym, + dim: sym, + ker: sym, + arg: sym, + Pr: sym, + // Limits + max: sym, + min: sym, + argmax: sym, + argmin: sym, + sup: sym, + inf: sym, + sum: sym, + prod: define-sym("product"), + int: define-sym("integral"), + iint: define-sym("integral.double"), + iiint: define-sym("integral.triple"), + oint: define-sym("integral.cont"), + oiint: define-sym("integral.surf"), + oiiint: define-sym("integral.vol"), + // Symbols + mod: define-sym("mod"), + bmod: define-sym("mod"), + cdot: define-sym("dot.c"), + cdotp: define-sym("dot.c"), + sdot: define-sym("dot.c"), + times: define-sym("times"), + oplus: define-sym("plus.circle"), + ominus: define-sym("minus.circle"), + pm: define-sym("plus.minus"), + plusmn: define-sym("plus.minus"), + mp: define-sym("minus.plus"), + div: define-sym("div"), + star: define-sym("star"), + cap: define-sym("sect"), + cup: define-sym("union"), + "in": define-sym("in"), + isin: define-sym("in"), + notin: define-sym("in.not"), + subset: define-sym("subset"), + subseteq: define-sym("subset.eq"), + subsetneqq: define-sym("⫋"), + ne: define-sym("!="), + neq: define-sym("!="), + lt: define-sym("<"), + gt: define-sym(">"), + le: define-sym("<="), + ge: define-sym(">="), + leq: define-sym("<="), + geq: define-sym(">="), + leqslant: define-sym("lt.eq.slant"), + geqslant: define-sym("gt.eq.slant"), + eqslantgtr: define-sym("⪖"), + eqslantless: define-sym("⪕"), + approx: define-sym("approx"), + leftarrow: define-sym("<-"), + rightarrow: define-sym("->"), + leftrightarrow: define-sym("<->"), + Leftarrow: define-sym("arrow.l.double"), + Rightarrow: define-sym("=>"), + Leftrightarrow: define-sym("<=>"), + larr: define-sym("<-"), + rarr: define-sym("->"), + lrarr: define-sym("<->"), + lArr: define-sym("arrow.l.double"), + rArr: define-sym("=>"), + lrArr: define-sym("<=>"), + Larr: define-sym("arrow.l.double"), + Rarr: define-sym("=>"), + Lrarr: define-sym("<=>"), + longleftarrow: define-sym("<--"), + longrightarrow: define-sym("-->"), + longleftrightarrow: define-sym("<-->"), + Longleftarrow: define-sym("<=="), + Longrightarrow: define-sym("==>"), + Longleftrightarrow: define-sym("<==>"), + to: define-sym("->"), + implies: define-sym("==>"), + impliedby: define-sym("<=="), + gets: define-sym("<-"), + mapsto: define-sym("|->"), + infty: define-sym("oo"), + lbrack: define-sym("bracket.l"), + rbrack: define-sym("bracket.r"), + lgroup: define-sym("⟮"), + rgroup: define-sym("⟯"), + llbracket: define-sym("bracket.l.double"), + rrbracket: define-sym("bracket.r.double"), + angle: define-sym("angle"), + lang: define-sym("angle.l"), + rang: define-sym("angle.r"), + langle: define-sym("angle.l"), + rangle: define-sym("angle.r"), + measuredangle: define-sym("angle.arc"), + sphericalangle: define-sym("angle.spheric"), + ast: define-sym("ast"), + checkmark: define-sym("checkmark"), + circledast: define-sym("ast.circle"), + backslash: define-sym("backslash"), + dagger: define-sym("dagger"), + ddagger: define-sym("dagger.double"), + circleddash: define-sym("dash.circle"), + odot: define-sym("dot.circle"), + bigodot: define-sym("dot.circle.big"), + boxdot: define-sym("dot.square"), + dots: define-sym("dots.h"), + cdots: define-sym("dots.h.c"), + ldots: define-sym("dots.h"), + vdots: define-sym("dots.v"), + ddots: define-sym("dots.down"), + dotsb: define-sym("dots.h.c"), + dotsc: define-sym("dots.h"), + dotsi: define-sym("dots.h.c"), + dotsm: define-sym("dots.h.c"), + dotso: define-sym("dots.h"), + sim: define-sym("tilde"), + simeq: define-sym("tilde.eq"), + backsimeq: define-sym("tilde.eq.rev"), + cong: define-sym("tilde.equiv"), + ncong: define-sym("tilde.equiv.not"), + nsim: define-sym("tilde.not"), + backsim: define-sym("tilde.rev"), + prime: define-sym("prime"), + backprime: define-sym("prime.rev"), + bigoplus: define-sym("plus.circle.big"), + dotplus: define-sym("plus.dot"), + boxplus: define-sym("plus.square"), + boxminus: define-sym("minus.square"), + eqsim: define-sym("minus.tilde"), + otimes: define-sym("times.circle"), + bigotimes: define-sym("times.circle.big"), + divideontimes: define-sym("times.div"), + leftthreetimes: define-sym("times.three.l"), + rightthreetimes: define-sym("times.three.r"), + ltimes: define-sym("times.l"), + rtimes: define-sym("times.r"), + boxtimes: define-sym("times.square"), + triangleq: define-sym("eq.delta"), + curlyeqprec: define-sym("eq.prec"), + curlyeqsucc: define-sym("eq.succ"), + gtrdot: define-sym("gt.dot"), + gg: define-sym("gt.double"), + gtreqless: define-sym("gt.eq.lt"), + gtreqqless: define-sym("⪌"), + ngeq: define-sym("gt.eq.not"), + geqq: define-sym("gt.equiv"), + gtrless: define-sym("gt.lt"), + gneqq: define-sym("gt.nequiv"), + ngtr: define-sym("gt.not"), + gnsim: define-sym("gt.ntilde"), + gtrsim: define-sym("gt.tilde"), + vartriangleright: define-sym("gt.tri"), + trianglerighteq: define-sym("gt.tri.eq"), + ntrianglerighteq: define-sym("gt.tri.eq.not"), + ntriangleright: define-sym("gt.tri.not"), + ggg: define-sym("gt.triple"), + lessdot: define-sym("lt.dot"), + ll: define-sym("lt.double"), + lesseqgtr: define-sym("lt.eq.gt"), + nleq: define-sym("lt.eq.not"), + leqq: define-sym("lt.equiv"), + lessgtr: define-sym("lt.gt"), + lneqq: define-sym("lt.nequiv"), + nless: define-sym("lt.not"), + lnsim: define-sym("lt.ntilde"), + lesssim: define-sym("lt.tilde"), + vartriangleleft: define-sym("lt.tri"), + trianglelefteq: define-sym("lt.tri.eq"), + ntrianglelefteq: define-sym("lt.tri.eq.not"), + ntriangleleft: define-sym("lt.tri.not"), + lll: define-sym("lt.triple"), + approxeq: define-sym("approx.eq"), + prec: define-sym("prec"), + precapprox: define-sym("prec.approx"), + preceq: define-sym("prec.eq"), + preccurlyeq: define-sym("prec.eq"), + npreceq: define-sym("prec.eq.not"), + precnapprox: define-sym("prec.napprox"), + nprec: define-sym("prec.not"), + precnsim: define-sym("prec.ntilde"), + precsim: define-sym("prec.tilde"), + succ: define-sym("succ"), + succapprox: define-sym("succ.approx"), + succeq: define-sym("succ.eq"), + succcurlyeq: define-sym("succ.eq"), + nsucceq: define-sym("succ.eq.not"), + succnapprox: define-sym("succ.napprox"), + nsucc: define-sym("succ.not"), + succnsim: define-sym("succ.ntilde"), + succsim: define-sym("succ.tilde"), + equiv: define-sym("equiv"), + propto: define-sym("prop"), + empty: define-sym("nothing"), + emptyset: define-sym("nothing"), + varnothing: define-sym("nothing"), + o: define-sym("nothing"), + O: define-sym("nothing"), + osplash: define-sym("⊘"), + setminus: define-sym("without"), + And: define-sym("amp"), + bigcirc: define-sym("circle.stroked.big"), + smallsetminus: define-sym("without"), + complement: define-sym("complement"), + ni: define-sym("in.rev"), + notni: define-sym("in.rev.not"), + Subset: define-sym("subset.double"), + nsubseteq: define-sym("subset.eq.not"), + sqsubseteq: define-sym("subset.eq.sq"), + subsetneq: define-sym("subset.neq"), + supset: define-sym("supset"), + Supset: define-sym("supset.double"), + supseteq: define-sym("supset.eq"), + nsupseteq: define-sym("supset.eq.not"), + sqsupseteq: define-sym("supset.eq.sq"), + supsetneq: define-sym("supset.neq"), + bigcup: define-sym("union.big"), + Cup: define-sym("union.double"), + uplus: define-sym("union.plus"), + biguplus: define-sym("union.plus.big"), + sqcup: define-sym("union.sq"), + bigsqcup: define-sym("union.sq.big"), + bigcap: define-sym("sect.big"), + Cap: define-sym("sect.double"), + sqcap: define-sym("sect.sq"), + partial: define-sym("diff"), + nabla: define-sym("nabla"), + coprod: define-sym("product.co"), + forall: define-sym("forall"), + exist: define-sym("exists"), + exists: define-sym("exists"), + nexists: define-sym("exists.not"), + top: define-sym("top"), + bot: define-sym("bot"), + neg: define-sym("not"), + lnot: define-sym("not"), + land: define-sym("and"), + wedge: define-sym("and"), + lor: define-sym("or"), + bigwedge: define-sym("and.big"), + curlywedge: define-sym("and.curly"), + vee: define-sym("or"), + bigvee: define-sym("or.big"), + curlyvee: define-sym("or.curly"), + models: define-sym("models"), + therefore: define-sym("therefore"), + because: define-sym("because"), + blacksquare: define-sym("qed"), + circ: define-sym("compose"), + multimap: define-sym("multimap"), + mid: define-sym("divides"), + nmid: define-sym("divides.not"), + wr: define-sym("wreath"), + parallel: define-sym("parallel"), + shortparallel: define-sym("parallel"), + nparallel: define-sym("parallel.not"), + perp: define-sym("perp"), + Join: define-sym("join"), + pounds: define-sym("pound"), + clubsuit: define-sym("suit.club"), + spadesuit: define-sym("suit.spade"), + bull: define-sym("bullet"), + bullet: define-sym("bullet"), + circledcirc: define-sym("circle.nested"), + rhd: define-sym("triangle.stroked.r"), + lhd: define-sym("triangle.stroked.l"), + triangle: define-sym("triangle.stroked.t"), + bigtriangleup: define-sym("triangle.stroked.t"), + bigtriangledown: define-sym("triangle.stroked.b"), + triangleright: define-sym("triangle.stroked.small.r"), + triangledown: define-sym("triangle.stroked.small.b"), + triangleleft: define-sym("triangle.stroked.small.l"), + vartriangle: define-sym("triangle.stroked.small.t"), + square: define-sym("square.stroked"), + Diamond: define-sym("diamond.stroked"), + diamond: define-sym("diamond.stroked.small"), + diamonds: define-sym("diamond.stroked"), + diamondsuit: define-sym("diamond.stroked"), + lozenge: define-sym("lozenge.stroked"), + blacklozenge: define-sym("lozenge.filled"), + bigstar: define-sym("star.stroked"), + longmapsto: define-sym("arrow.r.long.bar"), + nRightarrow: define-sym("arrow.r.double.not"), + hookrightarrow: define-sym("arrow.r.hook"), + looparrowright: define-sym("arrow.r.loop"), + nrightarrow: define-sym("arrow.r.not"), + rightsquigarrow: define-sym("arrow.r.squiggly"), + rightarrowtail: define-sym("arrow.r.tail"), + Rrightarrow: define-sym("arrow.r.triple"), + twoheadrightarrow: define-sym("arrow.r.twohead"), + nLeftarrow: define-sym("arrow.l.double.not"), + hookleftarrow: define-sym("arrow.l.hook"), + looparrowleft: define-sym("arrow.l.loop"), + nleftarrow: define-sym("arrow.l.not"), + leftarrowtail: define-sym("arrow.l.tail"), + Lleftarrow: define-sym("arrow.l.triple"), + twoheadleftarrow: define-sym("arrow.l.twohead"), + uparrow: define-sym("arrow.t"), + Uparrow: define-sym("arrow.t.double"), + downarrow: define-sym("arrow.b"), + Downarrow: define-sym("arrow.b.double"), + iff: define-sym("arrow.l.r.double.long"), + nLeftrightarrow: define-sym("arrow.l.r.double.not"), + nleftrightarrow: define-sym("arrow.l.r.not"), + leftrightsquigarrow: define-sym("arrow.l.r.wave"), + updownarrow: define-sym("arrow.t.b"), + Updownarrow: define-sym("arrow.t.b.double"), + nearrow: define-sym("arrow.tr"), + searrow: define-sym("arrow.br"), + nwarrow: define-sym("arrow.tl"), + swarrow: define-sym("arrow.bl"), + circlearrowleft: define-sym("arrow.ccw"), + curvearrowleft: define-sym("arrow.ccw.half"), + circlearrowright: define-sym("arrow.cw"), + curvearrowright: define-sym("arrow.cw.half"), + rightrightarrows: define-sym("arrows.rr"), + leftleftarrows: define-sym("arrows.ll"), + upuparrows: define-sym("arrows.tt"), + downdownarrows: define-sym("arrows.bb"), + leftrightarrows: define-sym("arrows.lr"), + rightleftarrows: define-sym("arrows.rl"), + rightharpoonup: define-sym("harpoon.rt"), + rightharpoondown: define-sym("harpoon.rb"), + leftharpoonup: define-sym("harpoon.lt"), + leftharpoondown: define-sym("harpoon.lb"), + upharpoonleft: define-sym("harpoon.tl"), + upharpoonright: define-sym("harpoon.tr"), + downharpoonleft: define-sym("harpoon.bl"), + downharpoonright: define-sym("harpoon.br"), + leftrightharpoons: define-sym("harpoons.ltrb"), + rightleftharpoons: define-sym("harpoons.rtlb"), + vdash: define-sym("tack.r"), + nvdash: define-sym("tack.r.not"), + vDash: define-sym("tack.r.double"), + nvDash: define-sym("tack.r.double.not"), + dashv: define-sym("tack.l"), + hbar: define-sym("planck.reduce"), + hslash: define-sym("planck.reduce"), + Re: define-sym("Re"), + Im: define-sym("Im"), + AA: define-sym("circle(A)"), + aa: define-sym("circle(A)"), + Box: define-sym("ballot"), + N: define-sym("NN"), + natnums: define-sym("NN"), + natural: define-sym("♮"), + P: define-sym("pilcrow"), + Q: define-sym("QQ"), + R: define-sym("RR"), + Z: define-sym("ZZ"), + S: define-sym("section"), + sect: define-sym("section"), + AE: define-sym("Æ"), + ae: define-sym("æ"), + alef: define-sym("aleph"), + alefsym: define-sym("aleph"), + aleph: define-sym("aleph"), + amalg: define-sym("product.co"), + arctg: of-sym(math.op("arctg")), + asymp: define-sym("≍"), + ch: of-sym(math.op("ch")), + circeq: define-sym("≗"), + colon: define-sym("colon"), + cth: of-sym(math.op("cth")), + dag: define-sym("dagger"), + Dagger: define-sym("dagger.double"), + ddag: define-sym("dagger.double"), + daleth: define-sym("ℸ"), + sharp: define-sym("♯"), + flat: define-sym("♭"), + i: define-sym("dotless.i"), + j: define-sym("dotless.j"), + imath: define-sym("dotless.i"), + jmath: define-sym("dotless.j"), + smallsmile: define-sym("⌣"), + smile: define-sym("⌣"), + ss: define-sym("ß"), + surd: define-sym("\√"), + tg: define-sym("tg"), + th: of-sym(math.op("th")), + weierp: define-sym("℘"), + wp: define-sym("℘"), + lbrace: define-sym("\\{"), + rbrace: define-sym("\\}"), + doteq: define-sym("≐"), + Vdash: define-sym("⊩"), + Doteq: define-sym("≑"), + Vvdash: define-sym("⊪"), + gnapprox: define-sym("⪊"), + ngeqslant: define-sym("gt.eq.not"), + precneqq: define-sym("prec.nequiv"), + gneq: define-sym("⪈"), + approxcolon: define-sym("approx:"), + approxcoloncolon: define-sym("approx::"), + backepsilon: define-sym("in.rev.small"), + barwedge: define-sym("⊼"), + beth: define-sym("beth"), + between: define-sym("≬"), + bigdot: define-sym("dot.circle.big"), + bigplus: define-sym("plus.circle.big"), + bigtimes: define-sym("times.circle.big"), + blacktriangle: define-sym("triangle.filled.t"), + blacktriangledown: define-sym("triangle.filled.b"), + blacktriangleleft: define-sym("triangle.filled.l"), + blacktriangleright: define-sym("triangle.filled.r"), + bowtie: define-sym("⋈"), + Bumpeq: define-sym("≎"), + bumpeq: define-sym("≏"), + centerdot: define-sym("dot.op"), + circledR: define-sym("®"), + circledS: define-sym("Ⓢ"), + clubs: define-sym("suit.club"), + cnums: define-sym("CC"), + Colonapprox: define-sym("::approx"), + colonapprox: define-sym(":approx"), + coloncolon: define-sym("::"), + coloncolonapprox: define-sym("::approx"), + coloncolonequals: define-sym("::="), + coloncolonminus: define-sym("::−"), + coloncolonsim: define-sym("::tilde.op"), + Coloneq: define-sym("::−"), + coloneq: define-sym(":−"), + colonequals: define-sym(":="), + Coloneqq: define-sym("::="), + coloneqq: define-sym(":="), + colonminus: define-sym(":−"), + Colonsim: define-sym("::tilde.op"), + colonsim: define-sym(":tilde.op"), + Complex: define-sym("CC"), + copyright: define-sym("copyright"), + ctg: define-sym("ctg"), + Darr: define-sym("arrow.b.double"), + dArr: define-sym("arrow.b.double"), + darr: define-sym("arrow.b"), + dashleftarrow: define-sym("arrow.l.dash"), + dashrightarrow: define-sym("arrow.r.dash"), + dbcolon: define-sym("::"), + degree: define-sym("degree"), + digamma: define-sym("ϝ"), + diagdown: define-sym("╲"), + diagup: define-sym("╱"), + doteqdot: define-sym("≑"), + doublebarwedge: define-sym("⩞"), + doublecap: define-sym("sect.double"), + doublecup: define-sym("union.double"), + eqcirc: define-sym("≖"), + Eqcolon: define-sym("−::"), + eqcolon: define-sym("dash.colon"), + Eqqcolon: define-sym("=::"), + eqqcolon: define-sym("=:"), + equalscolon: define-sym("=:"), + equalscoloncolon: define-sym("=::"), + eth: define-sym("ð"), + fallingdotseq: define-sym("≒"), + Finv: define-sym("Ⅎ"), + frown: define-sym("⌢"), + Game: define-sym("⅁"), + gggtr: define-sym(">>>"), + gimel: define-sym("gimel"), + Harr: define-sym("<=>"), + hArr: define-sym("<=>"), + harr: define-sym("<->"), + hearts: define-sym("♡"), + heartsuit: define-sym("♡"), + image: define-sym("Im"), + imageof: define-sym("⊷"), + infin: define-sym("infinity"), + intercal: define-sym("⊺"), + intop: define-sym("integral"), + lBrace: define-sym("⦃"), + ldotp: define-sym("."), + leadsto: define-sym("arrow.r.squiggly"), + lessapprox: define-sym("⪅"), + lesseqqgtr: define-sym("⪋"), + llcorner: define-sym("⌞"), + llless: define-sym("<<<"), + lnapprox: define-sym("⪉"), + gtrapprox: define-sym("⪆"), + lneq: define-sym("⪇"), + lrcorner: define-sym("⌟"), + lq: define-sym("quote.l.single"), + Lsh: define-sym("↰"), + maltese: define-sym("maltese"), + mathellipsis: define-sym("..."), + mathsterling: define-sym("pound"), + mho: define-sym("ohm.inv"), + minuscolon: define-sym("dash.colon"), + minuscoloncolon: define-sym("−::"), + minuso: define-sym("⦵"), + newline: define-sym("\\"), + nVDash: define-sym("⊯"), + nVdash: define-sym("⊮"), + OE: define-sym("Œ"), + oe: define-sym("œ"), + origof: define-sym("⊶"), + oslash: define-sym("⊘"), + owns: define-sym("in.rev"), + pitchfork: define-sym("⋔"), + ratio: define-sym(":"), + rBrace: define-sym("⦄"), + real: define-sym("Re"), + Reals: define-sym("RR"), + reals: define-sym("RR"), + restriction: define-sym("harpoon.tr"), + risingdotseq: define-sym("≓"), + rmoustache: define-sym("⎱"), + rq: define-sym("'"), + Rsh: define-sym("↱"), + simcolon: define-sym("tilde.op:"), + simcoloncolon: define-sym("tilde.op::"), + spades: define-sym("suit.spade"), + sqsubset: define-sym("subset.sq"), + sqsupset: define-sym("supset.sq"), + sub: define-sym("subset"), + sube: define-sym("subset.eq"), + subseteqq: define-sym("⫅"), + succneqq: define-sym("succ.nequiv"), + supe: define-sym("supset.eq"), + supseteqq: define-sym("⫆"), + supsetneqq: define-sym("⫌"), + textasciitilde: define-sym("~"), + textasciicircum: define-sym("\\^"), + textbackslash: define-sym("\\\\"), + textbar: define-sym("\\|"), + textbardbl: define-sym("‖"), + textbraceleft: define-sym("{"), + textbraceright: define-sym("}"), + textdagger: define-sym("dagger"), + textdaggerdbl: define-sym("dagger.double"), + textdegree: define-sym("degree"), + textdollarsign: define-sym("\\$"), + textellipsis: define-sym("..."), + textemdash: define-sym("---"), + textendash: define-sym("--"), + textgreater: define-sym("gt"), + textless: define-sym("lt"), + textquotedblleft: define-sym("quote.l.double"), + textquotedblright: define-sym("quote.r.double"), + textquoteleft: define-sym("quote.l.single"), + textquoteright: define-sym("quote.r.single"), + textregistered: define-sym("®"), + textsterling: define-sym("pound"), + textunderscore: define-sym("\\_"), + thetasym: define-sym("theta.alt"), + Uarr: define-sym("arrow.t.double"), + uArr: define-sym("arrow.t.double"), + uarr: define-sym("arrow.t"), + ulcorner: define-sym("⌜"), + unlhd: define-sym("lt.tri.eq"), + unrhd: define-sym("gt.tri.eq"), + urcorner: define-sym("⌝"), + varpropto: define-sym("prop"), + varsubsetneq: define-sym("subset.neq"), + varsubsetneqq: define-sym("⫋"), + varsupsetneq: define-sym("supset.neq"), + varsupsetneqq: define-sym("⫌"), + vcentcolon: define-sym(":"), + veebar: define-sym("⊻"), + yen: define-sym("yen"), + arcctg: of-sym(math.op("arcctg")), + begingroup: ignore-sym, + cosec: of-sym(math.op("cosec")), + cotg: of-sym(math.op("cotg")), + injlim: of-sym(math.op("inj\u{2009}lim", limits: true)), + mathclap: define-cmd(1, handle: it => box(width: 0pt, $it$)), + mathring: define-cmd(1, handle: it => math.circle(it)), + nobreak: ignore-sym, + noexpand: ignore-sym, + overgroup: define-cmd(1, handle: it => $accent(it, \u{0311})$), + undergroup: define-cmd(1, handle: it => $accent(it, \u{032e})$), + overleftharpoon: define-cmd(1, handle: it => $accent(it, \u{20d0})$), + overleftrightarrow: define-cmd(1, handle: it => $accent(it, \u{20e1})$), + overlinesegment: define-cmd(1, handle: it => $accent(it, \u{20e9})$), + overrightharpoon: define-cmd(1, handle: it => $accent(it, \u{20d1})$), + underbar: define-cmd(1, handle: it => $underline(it)$), + plim: of-sym(math.op("plim", limits: true)), + projlim: of-sym(math.op("proj\u{2009}lim", limits: true)), + raisebox: define-cmd(2, handle: (sp, it) => text(baseline: -mitex-safe-length(get-tex-str(sp)), it)), + sh: of-sym(math.op("sh")), + smallint: of-sym($inline(integral)$), + thickapprox: of-sym($bold(approx)$), + thicksim: of-sym($bold(tilde)$), + varDelta: of-sym($italic(Delta)$), + varGamma: of-sym($italic(Gamma)$), + varLambda: of-sym($italic(Lambda)$), + varOmega: of-sym($italic(Omega)$), + varPhi: of-sym($italic(Phi)$), + varPi: of-sym($italic(Pi)$), + varPsi: of-sym($italic(Psi)$), + varSigma: of-sym($italic(Sigma)$), + varTheta: of-sym($italic(Theta)$), + varUpsilon: of-sym($italic(Upsilon)$), + varXi: of-sym($italic(Xi)$), + xleftarrow: arrow-handle(math.arrow.l), + xrightarrow: arrow-handle(math.arrow.r), + xLeftarrow: arrow-handle(math.arrow.l.double), + xRightarrow: arrow-handle(math.arrow.r.double), + xleftrightarrow: arrow-handle(math.arrow.l.r), + xLeftrightarrow: arrow-handle(math.arrow.l.r.double), + xhookleftarrow: arrow-handle(math.arrow.l.hook), + xhookrightarrow: arrow-handle(math.arrow.r.hook), + xtwoheadleftarrow: arrow-handle(math.arrow.l.twohead), + xtwoheadrightarrow: arrow-handle(math.arrow.r.twohead), + xleftharpoonup: arrow-handle(math.harpoon.lt), + xrightharpoonup: arrow-handle(math.harpoon.rt), + xleftharpoondown: arrow-handle(math.harpoon.lb), + xrightharpoondown: arrow-handle(math.harpoon.rb), + xleftrightharpoons: arrow-handle(math.harpoons.ltrb), + xrightleftharpoons: arrow-handle(math.harpoons.rtlb), + xtofrom: arrow-handle(math.arrows.rl), + xmapsto: arrow-handle(math.arrow.r.bar), + xlongequal: arrow-handle(math.eq), + pmod: define-cmd(1, handle: it => $quad (mod thick it)$), + pod: define-cmd(1, handle: it => $quad (it)$), + "set": define-cmd(1, handle: it => $\{it\}$), + Set: define-cmd(1, handle: it => $lr(\{it\})$), + bra: define-cmd(1, handle: it => $angle.l it|$), + Bra: define-cmd(1, handle: it => $lr(angle.l it|)$), + ket: define-cmd(1, handle: it => $|it angle.r$), + Ket: define-cmd(1, handle: it => $lr(|it angle.r)$), + braket: define-cmd(1, handle: it => $angle.l it angle.r$), + Braket: define-cmd(1, handle: it => $lr(angle.l it angle.r)$), + fbox: define-cmd(1, handle: it => box(stroke: 0.5pt, $it$)), + hbox: define-cmd(1, handle: it => it), + // Matrices + matrix: matrix-handle(delim: none), + pmatrix: matrix-handle(delim: "("), + bmatrix: matrix-handle(delim: "["), + Bmatrix: matrix-handle(delim: "{"), + vmatrix: matrix-handle(delim: "|"), + Vmatrix: matrix-handle(delim: "||"), + smallmatrix: matrix-handle(handle: (..args) => math.inline(math.mat.with(delim: none, ..args))), + array: define-env(1, kind: "is-matrix", alias: "mitexarray", handle: (arg0: ("l",), ..args) => { + if args.pos().len() == 0 { + return + } + if type(arg0) != str { + if arg0.has("children") { + arg0 = arg0.children.filter(it => it != [ ] and it != [#math.zws]) + .map(it => it.text) + .filter(it => it == "l" or it == "c" or it == "r") + } else { + arg0 = (arg0.text,) + } + } + let matrix = args.pos().map(row => if type(row) == array { row } else { (row,) } ) + let n = matrix.len() + let m = calc.max(..matrix.map(row => row.len())) + matrix = matrix.map(row => row + (m - row.len()) * (none,)) + let array-at(arr, pos) = { + arr.at(calc.min(pos, arr.len() - 1)) + } + let align-map = ("l": left, "c": center, "r": right) + set align(align-map.at(array-at(arg0, 0))) + pad(y: 0.2em, grid( + columns: m, + column-gutter: 0.5em, + row-gutter: 0.5em, + ..matrix.flatten().map(it => $it$) + )) + }), + subarray: define-env(1, kind: "is-matrix", alias: "mitexarray"), + // Environments + aligned: define-env(none, kind: "is-math", alias: "aligned", handle: call-or-ignore(it => pad(y: 0.2em, block(math.op(math.display(it)))))), + alignedat: define-env(1, kind: "is-math", alias: "alignedat", handle: (arg0: none, it) => pad(y: 0.2em, block(math.op(it)))), + align: define-env(none, kind: "is-math", alias: "aligned"), + "align*": define-env(none, kind: "is-math", alias: "aligned"), + equation: define-env(none, kind: "is-math", alias: "aligned"), + "equation*": define-env(none, kind: "is-math", alias: "aligned"), + split: define-env(none, kind: "is-math", alias: "aligned"), + gather: define-env(none, kind: "is-math", alias: "aligned"), + gathered: define-env(none, kind: "is-math", alias: "aligned"), + cases: define-env(none, kind: "is-cases", alias: "cases"), + rcases: define-env(none, kind: "is-cases", alias: "rcases", handle: math.cases.with(reverse: true)), + // Specials + notag: ignore-sym, + relax: ignore-sym, + cr: ignore-sym, + expandafter: ignore-sym, + hskip: ignore-sym, + mskip: ignore-sym, + kern: ignore-sym, + mkern: ignore-sym, + mathstrut: ignore-sym, + nonumber: ignore-sym, + KaTeX: of-sym(math.upright($kai A T E X$)), + LaTeX: of-sym(math.upright($L A T E X$)), + TeX: of-sym(math.upright($T E X$)), + middle: define-cmd(1, handle: it => math.mid(it)), + operatorname: define-cmd(1, handle: it => math.op(math.upright(it))), + operatornamewithlimits: define-cmd(1, alias: "operatornamewithlimits", handle: operatornamewithlimits), + "operatorname*": define-cmd(1, alias: "operatornamewithlimits", handle: operatornamewithlimits), + vspace: define-cmd(1, handle: it => v(mitex-safe-length(get-tex-str(it)))), + hspace: define-cmd(1, handle: it => h(mitex-safe-length(get-tex-str(it)))), + text: define-cmd(1, alias: "#textmath", handle: it => it), + textmd: define-cmd(1, alias: "#textmd", handle: it => it), + textnormal: define-cmd(1, alias: "#textnormal", handle: it => it), + textbf: define-cmd(1, alias: "#textbf", handle: math.bold), + textit: define-cmd(1, alias: "#textit", handle: math.italic), + textrm: define-cmd(1, alias: "#textrm", handle: math.upright), + textup: define-cmd(1, alias: "#textup", handle: math.upright), + textsf: define-cmd(1, alias: "#textsf", handle: math.sans), + texttt: define-cmd(1, alias: "#texttt", handle: math.mono), + over: define-infix-cmd("frac"), + atop: define-infix-cmd("atop", handle: (a, b) => $mat(delim: #none, #a; #b)$), + choose: define-infix-cmd("binom", handle: math.binom), + brace: define-infix-cmd("brace", handle: (n, k) => $mat(delim: "{", #n;; #k)$), + brack: define-infix-cmd("brack", handle: (n, k) => $mat(delim: "[", #n;; #k)$), + sqrt: define-glob-cmd("{,b}t", "mitexsqrt", handle: (..args) => { + if args.pos().len() == 1 { + $sqrt(#args.pos().at(0))$ + } else if args.pos().len() == 2 { + $root( + #args.pos().at(0).children.filter(it => it != [\[] and it != [\]]).sum(), + #args.pos().at(1) + )$ + } else { + panic("unexpected args in sqrt") + } + }), + // todo: macros + def: ignore-sym, + newcommand: ignore-sym, + "newcommand*": ignore-sym, + renewcommand: ignore-sym, + "renewcommand*": ignore-sym, + DeclareRobustCommand: ignore-sym, + "DeclareRobustCommand*": ignore-sym, + DeclareTextCommand: ignore-sym, + DeclareTextCommandDefault: ignore-sym, + ProvideTextCommand: ignore-sym, + ProvideTextCommandDefault: ignore-sym, + providecommand: ignore-sym, + "providecommand*": ignore-sym, + newenvironment: ignore-sym, + "newenvironment*": ignore-sym, + renewenvironment: ignore-sym, + "renewenvironment*": ignore-sym, + AtEndOfClass: ignore-sym, + AtEndOfPackage: ignore-sym, + AtBeginDocument: ignore-sym, + AtEndDocument: ignore-sym, + "@ifstar": ignore-sym, + "if": ignore-sym, + ifdim: ignore-sym, + iffalse: ignore-sym, + ifnum: ignore-sym, + ifodd: ignore-sym, + iftrue: ignore-sym, + ifx: ignore-sym, + DeclareOption: ignore-sym, + "DeclareOption*": ignore-sym, + CurrentOption: ignore-sym, + ProcessOptions: ignore-sym, + ExecuteOptions: ignore-sym, + RequirePackage: ignore-sym, + RequirePackageWithOptions: ignore-sym, + documentclass: ignore-sym, + PassOptionsToClass: ignore-sym, + PassOptionsToPackage: ignore-sym, + IfFileExists: ignore-sym, + InputIfFileExists: ignore-sym, + ProvidesFile: ignore-sym, + ignorespaces: ignore-sym, + ignorespacesafterend: ignore-sym, + ifvoid: ignore-sym, + ifinner: ignore-sym, + ifhbox: ignore-sym, + ifvbox: ignore-sym, + ifhmode: ignore-sym, + ifmmode: ignore-sym, + ifvmode: ignore-sym, + CheckCommand: ignore-sym, + "CheckCommand*": ignore-sym, + newcounter: ignore-sym, + newlength: ignore-sym, + newsavebox: ignore-sym, + newtheorem: ignore-sym, + newfont: ignore-sym, + ProvidesClass: ignore-sym, + LoadClass: ignore-sym, + LoadClassWithOptions: ignore-sym, +)) + +// export: include package name, spec and scope +#let package = (name: "latex-std", spec: (commands: spec), scope: scope) diff --git a/src-tauri/vendor/mitex/specs/mod.typ b/src-tauri/vendor/mitex/specs/mod.typ new file mode 100644 index 0000000..6b5d957 --- /dev/null +++ b/src-tauri/vendor/mitex/specs/mod.typ @@ -0,0 +1,12 @@ + +#import "prelude.typ": * +#import "latex/standard.typ": package as latex-std + +// 1. import all the packages and form a mitex-scope for mitex to use +#let packages = (latex-std,) +#let mitex-scope = packages.map(pkg => pkg.scope).sum() + +// 2. export all packages with specs by metadata and label, +// mitex-cli can fetch them by +// `typst query --root . ./packages/mitex/specs/mod.typ ""` +#metadata(packages) diff --git a/src-tauri/vendor/mitex/specs/prelude.typ b/src-tauri/vendor/mitex/specs/prelude.typ new file mode 100644 index 0000000..70789f0 --- /dev/null +++ b/src-tauri/vendor/mitex/specs/prelude.typ @@ -0,0 +1,230 @@ +/// Define a normal symbol, as no-argument commands like \alpha +/// +/// Arguments: +/// - s (str): Alias command for typst handler. +/// For example, alias `\prod` to typst's `product`. +/// - sym (content): The specific content, as the value of alias in mitex-scope. +/// For example, there is no direct alias for \negthinspace symbol in typst, +/// but we can add `h(-(3/18) * 1em)` ourselves +/// +/// Return: A spec item and a scope item (none for no scope item) +#let define-sym(s, sym: none) = { + ( + (kind: "alias-sym", alias: s), + if sym != none { + (alias: s, handle: sym) + } else { + none + }, + ) +} + +/// Define a greedy command, like \displaystyle +/// +/// Arguments: +/// - s (str): Alias command for typst handler. +/// For example, alias `\displaystyle` to typst's `mitexdisplay`, as the key in mitex-scope. +/// - handle (function): The handler function, as the value of alias in mitex-scope. +/// It receives a content argument as all greedy matches to the content +/// For example, we define `mitexdisplay` to `math.display` +/// +/// Return: A spec item and a scope item (none for no scope item) +#let define-greedy-cmd(s, handle: none) = { + ( + (kind: "greedy-cmd", alias: s), + if handle != none { + (alias: s, handle: handle) + } else { + none + }, + ) +} + +/// Define an infix command, like \over +/// +/// Arguments: +/// - s (str): Alias command for typst handler. +/// For example, alias `\over` to typst's `frac`, as the key in mitex-scope. +/// - handle (function): The handler function, as the value of alias in mitex-scope. +/// It receives two content arguments, as (prev, after) arguments. +/// For example, we define `\over` to `frac: (num, den) => $(num)/(den)$` +/// +/// Return: A spec item and a scope item (none for no scope item) +#let define-infix-cmd(s, handle: none) = { + ( + (kind: "infix-cmd", alias: s), + if handle != none { + (alias: s, handle: handle) + } else { + none + }, + ) +} + +/// Define a glob (Global Wildcard) match command with a specified pattern for matching args +/// Kind of item to match: +/// - Bracket/b: [] +/// - Parenthesis/p: () +/// - Term/t: any rest of terms, typically {} or single char +/// +/// Arguments: +/// - pat (pattern): The pattern for glob-cmd +/// For example, `{,b}t` for `\sqrt` to support `\sqrt{2}` and `\sqrt[3]{2}` +/// - s (str): Alias command for typst handler. +/// For example, alias `\sqrt` to typst's `mitexsqrt`, as the key in mitex-scope. +/// - handle (function): The handler function, as the value of alias in mitex-scope. +/// It receives variable length arguments, for example `(2,)` or `([3], 2)` for sqrt. +/// Therefore you need to use `(.. arg) = > {..}` to receive them. +/// +/// Return: A spec item and a scope item (none for no scope item) +#let define-glob-cmd(pat, s, handle: none) = { + ( + (kind: "glob-cmd", pattern: pat, alias: s), + if handle != none { + (alias: s, handle: handle) + } else { + none + }, + ) +} + +/// Define a command with a fixed number of arguments, like \hat{x} and \frac{1}{2} +/// +/// Arguments: +/// - num (int): The number of arguments for the command. +/// - alias (str): Alias command for typst handler. +/// For example, alias `\frac` to typst's `frac`, as the key in mitex-scope. +/// - handle (function): The handler function, as the value of alias in mitex-scope. +/// It receives fixed number of arguments, for example `frac(1, 2)` for `\frac{1}{2}`. +/// +/// Return: A spec item and a scope item (none for no scope item) +#let define-cmd(num, alias: none, handle: none) = { + ( + ( + kind: "cmd", + args: ("kind": "right", "pattern": (kind: "fixed-len", len: num)), + alias: alias, + ), + if handle != none { + (alias: alias, handle: handle) + } else { + none + }, + ) +} + +/// Define an environment with a fixed number of arguments, like \begin{alignedat}{2} +/// +/// Arguments: +/// - num (int): The number of arguments as environment options for the environment. +/// - alias (str): Alias command for typst handler. +/// For example, alias `\begin{alignedat}{2}` to typst's `alignedat`, +/// and alias `\begin{aligned}` to typst's `aligned`, as the key in mitex-scope. +/// - kind (str): environment kind, it could be "is-math", "is-cases", "is-matrix", +/// "is-itemize", "is-enumerate" +/// - handle (function): The handler function, as the value of alias in mitex-scope. +/// It receives fixed number of named arguments as environment options, +/// for example `alignedat(arg0: ..)` or `alignedat(arg0: .., arg1: ..)`. +/// And it receives variable length arguments as environment body, +/// Therefore you need to use `(.. arg) = > {..}` to receive them. +/// +/// Return: A spec item and a scope item (none for no scope item) +#let define-env(num, kind: "none", alias: none, handle: none) = { + ( + ( + kind: "env", + args: if num != none { + (kind: "fixed-len", len: num) + } else { + (kind: "none") + }, + ctx_feature: (kind: kind), + alias: alias, + ), + if handle != none { + (alias: alias, handle: handle) + } else { + none + }, + ) +} + +#let define-glob-env(pat, kind: "none", alias: none, handle: none) = { + ( + ( + kind: "glob-env", + pattern: pat, + ctx_feature: (kind: kind), + alias: alias, + ), + if handle != none { + (alias: alias, handle: handle) + } else { + none + }, + ) +} + +/// Define a symbol without alias and without handler function, like \alpha => alpha +/// +/// Return: A spec item and no scope item (none for no scope item) +#let sym = ((kind: "sym"), none) + +/// Define a symbol without alias and with handler function, +/// like \negthinspace => h(-(3/18) * 1em) +/// +/// Arguments: +/// - handle (function): The handler function, as the value of alias in mitex-scope. +/// For example, define `negthinspace` to handle `h(-(3/18) * 1em)` in mitex-scope +/// +/// Return: A symbol spec and a scope item +#let of-sym(handle) = ((kind: "sym"), (handle: handle)) + +/// Define a left1-op command without handler, like `\limits` for `\sum\limits` +/// +/// Arguments: +/// - alias (str): Alias command for typst handler. +/// For example, alias `\limits` to typst's `limits` +/// and alias `\nolimits` to typst's `scripts` +/// +/// Return: A cmd spec and no scope item (none for no scope item) +#let left1-op(alias) = ((kind: "cmd", args: (kind: "left1"), alias: alias), none) + +/// Define a cmd1 command like \hat{x} => hat(x) +/// +/// Return: A cmd1 spec and a scope item (none for no scope item) +#let cmd1 = ((kind: "cmd1"), none) + +/// Define a cmd2 command like \binom{1}{2} => binom(1, 2) +/// +/// Return: A cmd2 spec and a scope item (none for no scope item) +#let cmd2 = ((kind: "cmd2"), none) + +/// Define a matrix environment without handler +/// +/// Return: A matrix-env spec and a scope item (none for no scope item) +#let matrix-env = ((kind: "matrix-env"), none) + +/// Receives a list of definitions composed of the above functions, and processes them to return a dictionary containing spec and scope. +#let process-spec(definitions) = { + let spec = (:) + let scope = (:) + for (key, value) in definitions.pairs() { + let spec-item = value.at(0) + let scope-item = value.at(1) + spec.insert(key, spec-item) + if scope-item != none { + if "alias" in scope-item and type(scope-item.alias) == str { + let key = if scope-item.alias.starts-with("#") { + scope-item.alias.slice(1) + } else { + scope-item.alias + } + scope.insert(key, scope-item.handle) + } else { + scope.insert(key, scope-item.handle) + } + } + } + (spec: spec, scope: scope) +} diff --git a/src/App.tsx b/src/App.tsx index aa9a18e..b358c07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,15 +20,17 @@ import { FindInFiles } from "./components/FindInFiles"; import { ProofPopover } from "./components/ProofPopover"; import { QuickOpen } from "./components/QuickOpen"; import { Recents } from "./components/Recents"; +import { Settings } from "./components/Settings"; import { Shortcuts } from "./components/Shortcuts"; import { Sidebar } from "./components/Sidebar"; import { Titlebar } from "./components/Titlebar"; import { Toast } from "./components/Toast"; +import { UpdateDialog } from "./components/UpdateDialog"; import { flushPendingSave, keepBuffer } from "./document"; import { DocumentEditor, PlainTextEditor, useDocumentFind } from "./editor"; import { Toolbar, type ToolbarSaveState } from "./editor/Toolbar"; import { MENU_ACTION_EVENT, isDesktop, isTauri } from "./ipc"; -import { onCommand, type CommandId } from "./keys/commands"; +import { onCommand } from "./keys/commands"; import { useKeymap } from "./keys/keymap"; import { handleMenuAction } from "./keys/menu"; import { openLink } from "./links"; @@ -36,20 +38,11 @@ import { documentKindForPath } from "./model/doc"; import { useDocument } from "./store/useDocument"; import { notify } from "./store/useToast"; import { useWorkspace } from "./store/useWorkspace"; +import { startUpdateChecks } from "./update"; import { useCompact, useTouch } from "./useMedia"; import { applyWidth } from "./width"; import { restoreSession, startWorkspaceEvents } from "./workspace"; -/** - * Commands whose whole result is a panel that this milestone does not have. They are bound keys - * and native menu rows already, so pressing one has to say something: a key that silently does - * nothing reads as a broken app rather than as an unfinished one. Each line goes when its panel - * arrives. - */ -const UNBUILT: ReadonlyArray<[CommandId, string]> = [ - ["settings", "There is no settings panel yet. The theme is in the title bar."], -]; - const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1); function App() { @@ -109,13 +102,17 @@ function App() { onCommand("editor-width-narrow", () => applyWidth("narrow")), onCommand("editor-width-normal", () => applyWidth("normal")), onCommand("editor-width-wide", () => applyWidth("wide")), - ...UNBUILT.map(([id, message]) => onCommand(id, () => notify(message))), ]; return () => { for (const stop of stops) stop(); }; }, []); + // The check the app makes on its own, which is off unless the setting says otherwise and silent + // unless it finds something. Pressing Check for Updates goes through the command table instead + // and answers either way, because somebody who asked is owed a sentence. + useEffect(() => startUpdateChecks(), []); + const conflict = externalChange === "changed-on-disk"; // Asked once, when the conflict appears. Dismissing it leaves the warning in the toolbar to @@ -181,6 +178,8 @@ function App() { + + {resolving && conflict && path !== null && ( diff --git a/src/api/grammar.ts b/src/api/grammar.ts new file mode 100644 index 0000000..8f8e4c6 --- /dev/null +++ b/src/api/grammar.ts @@ -0,0 +1,17 @@ +// Grammar, which is Harper's. +// +// The same shape as src/api/spell.ts on purpose: a run of text goes over, problems come back with +// offsets counted from the start of that run, and neither side knows a document exists. That is +// what lets the editor send one paragraph and add its own base offset afterwards, and it is why +// the two checkers can share a decoration pipeline instead of growing a second one. + +import { call, type GrammarIssue } from "../ipc"; + +/** + * Whether this build has a grammar engine behind it. False hides the underlines and the popover's + * grammar half rather than offering a check that answers nothing, exactly as `spellAvailable` does. + */ +export const grammarAvailable = () => call("grammar_available"); + +/** Every grammar problem in one run of text, with offsets in characters from the start of it. */ +export const grammarCheck = (text: string) => call("grammar_check", { text }); diff --git a/src/api/pdf.ts b/src/api/pdf.ts new file mode 100644 index 0000000..79718ce --- /dev/null +++ b/src/api/pdf.ts @@ -0,0 +1,20 @@ +// PDF export. The source is built here and compiled there; these two calls are the boundary. + +import { call, type ImageInput } from "../ipc"; + +/** + * Compiles Typst source to PDF bytes. Warnings do not come back through the return value, because + * the answer is raw bytes and has no room for a second one: they arrive on `pdf-warnings` instead. + */ +export const pdfCompile = (source: string, images: readonly ImageInput[]) => + call("pdf_compile", { source, images }); + +/** + * Writes the finished file wherever the native save panel pointed. + * + * `Array.from` because the IPC boundary is JSON and a `Uint8Array` stringifies to an object with + * numeric keys, which is not a `Vec` on the other side. One export's worth of numbers is the + * cost of not inventing a second transport for a thing that happens once per document. + */ +export const pdfWrite = (path: string, bytes: Uint8Array) => + call("pdf_write", { path, bytes: Array.from(bytes) }); diff --git a/src/api/writing.ts b/src/api/writing.ts new file mode 100644 index 0000000..8634807 --- /dev/null +++ b/src/api/writing.ts @@ -0,0 +1,17 @@ +// Writing Tools, which is Apple's and not this app's. +// +// There is no API behind these two calls in the way there is behind spelling: the Rust side finds +// the system's own menu item and performs it, and the rewrite that follows lands in the webview by +// itself. Nothing here returns the new text, because nothing here is told what it is. + +import { call } from "../ipc"; + +/** + * Whether this machine can run a Writing Tool at all. It needs macOS 15.1 with Apple Intelligence + * turned on, which is a much newer Mac than this app's minimum, so false is an ordinary answer and + * the caller turns it into a toast rather than a button that does nothing. + */ +export const writingAvailable = () => call("writing_available"); + +/** Fires one item on the system's Writing Tools submenu, by its English title. */ +export const writingRun = (tool: string) => call("writing_run", { tool }); diff --git a/src/components/ProofPopover.tsx b/src/components/ProofPopover.tsx index 3482649..22ceb25 100644 --- a/src/components/ProofPopover.tsx +++ b/src/components/ProofPopover.tsx @@ -1,5 +1,14 @@ -// The menu over a misspelled word: what the system thinks was meant, and the two ways of saying it -// was not a mistake. +// The menu over an underlined word or phrase: what the checker thinks was meant, and the ways of +// saying it was not a mistake. +// +// One menu for both checkers, because from the reader's side there is one underline under one piece +// of their writing and it is either wrong or it is not. What changes with the kind is the top of the +// menu and the bottom of it. Grammar gets a heading: Harper's category for the rule and the sentence +// it wrote, because a grammar correction is a judgement in a way a spelling one is not, and taking +// "their own" over "there own" without being told why is how a checker teaches somebody nothing. +// Spelling keeps "Learn Spelling", and grammar does not get it: the dictionary that item writes to +// is the Mac's own and it holds words, so offering it for a phrase would be offering something that +// cannot happen. // // Mounted once and drawing nothing until src/editor/proofing.ts puts a word in the store, so App.tsx // holds one line for it rather than a piece of the feature. It renders into a portal because the @@ -44,6 +53,23 @@ const MARGIN = 8; /** Everything the arrow keys walk, which is every item and not only the suggestions. */ const ITEM = ".proof-suggestion, .proof-action"; +/** + * Harper's rule categories arrive as the names of its own enum: "WordChoice" is a serviceable key + * and a poor heading, so the words are split apart for the one place a person reads it. + */ +function readableKind(kind: string): string { + return kind.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); +} + +/** + * A suggestion that deletes the text rather than replacing it arrives as an empty string, which is + * the correct thing to write and not something to put on a button. Harper offers it for a repeated + * word and for a redundancy, where what is being suggested really is "take this out". + */ +function suggestionLabel(suggestion: string): string { + return suggestion === "" ? "Remove" : suggestion; +} + /** * The chord's end of the feature, and the one line of explanation it owes when there is nothing * beside the caret to correct. @@ -148,6 +174,17 @@ function ProofMenu({ target }: { target: ProofTarget }) { e.stopPropagation(); }; + const grammar = target.grammar; + // Everything in the menu that is prose rather than a choice, in the order it is drawn, so a + // reader is told what the checker said and then what the menu can do about it. + const described = [ + grammar ? `${ids}-message` : null, + target.suggestions.length === 0 ? `${ids}-none` : null, + grammar ? null : `${ids}-note`, + ] + .filter((id) => id !== null) + .join(" "); + /** * The walk, and the trap. * @@ -172,18 +209,28 @@ function ProofMenu({ target }: { target: ProofTarget }) { ref={popRef} className="proof-pop" role="menu" - aria-label={`Spelling suggestions for ${target.word}`} - aria-describedby={ - target.suggestions.length === 0 ? `${ids}-none ${ids}-note` : `${ids}-note` - } + aria-label={`${grammar ? "Grammar" : "Spelling"} suggestions for ${target.word}`} + aria-describedby={described} style={{ left: at.left, top: at.top }} onKeyDown={onKeyDown} onContextMenu={(e) => e.preventDefault()} > - {/* The two paragraphs and the rule are not items, so a menu announces the things that can be - chosen and nothing else. Neither line is thrown away with them: both are named on the - menu's own aria-describedby above, which is where a sentence about a menu belongs and is + {/* None of the paragraphs and none of the rules are items, so a menu announces the things that + can be chosen and nothing else. None of the lines is thrown away with them: each is named on + the menu's own aria-describedby above, which is where a sentence about a menu belongs and is what gets them read once, on the way in, rather than as a row somebody has to arrow past. */} + {grammar && ( + <> +

+ {readableKind(grammar.kind)} +

+ +
+ + )} + {target.suggestions.length === 0 ? (
+ {!grammar && ( + + )} - - + {!grammar && ( + + )}
, document.body, ); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx new file mode 100644 index 0000000..ed1ae18 --- /dev/null +++ b/src/components/Settings.tsx @@ -0,0 +1,192 @@ +// Cmd+, and the Settings row on the app menu. Four things, and deliberately not a fifth: which +// version is running, whether there is a newer one, and the two proofing checkers. +// +// Everything else this app can be told is already somewhere better. The theme is in the title bar, +// the editor width is in the toolbar, and both of them are one click from the thing they change. +// Moving either in here would be a second place to look for a setting that already has a first one, +// so this panel holds only what has nowhere else to live. +// +// A checker the machine does not have leaves its row on screen and turns it off. The store's own +// comment argues for taking a missing checker off the screen entirely, and that is right for +// underlines and for the correction menu: an underline that is absent explains itself. A settings +// panel is where somebody goes to look for a setting, and a row that is simply not there reads as a +// missing feature rather than as a missing checker, so the row stays and says which it is. + +import { useEffect, useState } from "react"; +import { useEscapeLayer } from "../escape"; +import { onCommand } from "../keys/commands"; +import { useKeyContext } from "../keys/keymap"; +import { useProofing } from "../store/useProofing"; +import { useUpdate } from "../store/useUpdate"; +import { appVersion, checkForUpdates } from "../update"; +import { Icon } from "./Icon"; + +/** + * Which build this is, decided at bundle time. `pnpm dev`, and `tauri dev` on top of it, are the + * development one; a bundle built through `pnpm build` is the other. It is here because it is the + * first thing worth knowing when the updater says it is not enabled. + */ +const BUILD = import.meta.env.DEV ? "development build" : "release build"; + +function checkedLabel(at: number | null): string { + if (at === null) return "Not checked yet"; + const seconds = Math.max(0, Math.round((Date.now() - at) / 1000)); + if (seconds < 90) return "Checked just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `Checked ${minutes} minutes ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return hours === 1 ? "Checked an hour ago" : `Checked ${hours} hours ago`; + const days = Math.round(hours / 24); + if (days < 30) return days === 1 ? "Checked yesterday" : `Checked ${days} days ago`; + return `Checked on ${new Date(at).toLocaleDateString()}`; +} + +interface SettingRowProps { + label: string; + /** The quiet second line. Empty means the row is one line tall. */ + note: string; + on: boolean; + disabled?: boolean; + onChange: (on: boolean) => void; +} + +function SettingRow({ label, note, on, disabled = false, onChange }: SettingRowProps) { + return ( +
+ + {label} + {note !== "" && {note}} + + +
+ ); +} + +export function Settings() { + const [open, setOpen] = useState(false); + const [version, setVersion] = useState(null); + + const phase = useUpdate((s) => s.phase); + const lastChecked = useUpdate((s) => s.lastChecked); + const automatic = useUpdate((s) => s.automatic); + const setAutomatic = useUpdate((s) => s.setAutomatic); + + const spelling = useProofing((s) => s.enabled); + const setSpelling = useProofing((s) => s.setEnabled); + const spellingAvailability = useProofing((s) => s.availability); + const grammar = useProofing((s) => s.grammar); + const setGrammar = useProofing((s) => s.setGrammar); + const grammarAvailability = useProofing((s) => s.grammarAvailability); + const ensureAvailable = useProofing((s) => s.ensureAvailable); + + useEffect(() => onCommand("settings", () => setOpen((v) => !v)), []); + useEscapeLayer(open, () => setOpen(false)); + useKeyContext("overlay", open); + + // Both asked on the way in rather than at launch. The editor asks the same two questions the + // first time it draws a document, and the store answers each of them once per run whichever of + // us gets there first. + useEffect(() => { + if (!open) return; + ensureAvailable(); + let live = true; + void appVersion().then((v) => { + if (live) setVersion(v); + }); + return () => { + live = false; + }; + }, [open, ensureAvailable]); + + if (!open) return null; + + const checking = phase === "checking"; + const spellingMissing = spellingAvailability === "missing"; + const grammarMissing = grammarAvailability === "missing"; + + return ( +
setOpen(false)}> +
e.stopPropagation()} + > +
+

Settings

+ +
+ +
+
+
Margin Docs
+
+ {version === null ? BUILD : `Version ${version}, ${BUILD}`} +
+
+ +
+
Updates
+
+ + Software update + {checkedLabel(lastChecked)} + + +
+ +
+ +
+
Proofing
+ + +
+
+
+
+ ); +} diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx new file mode 100644 index 0000000..42d03af --- /dev/null +++ b/src/components/UpdateDialog.tsx @@ -0,0 +1,147 @@ +// There is a new version, and this is what the app says about it before it replaces itself. +// +// A toast was the earlier answer and it was the wrong one twice over: four seconds is not long +// enough to read release notes, and an update that installs itself the moment it is found takes a +// decision away from somebody who might be in the middle of a sentence. So the two answers are on +// screen at once, Later costs nothing, and the download is watched rather than waited on. +// +// Escape and the close button are live while the dialog is asking and gone once it is downloading, +// because dismissing a download that carries on in the background is a lie about what happened. +// There is no cancel: the plugin's download has no handle to stop it, and a button that only stops +// the dialog would be a worse promise than no button. + +import { useEffect, useRef } from "react"; +import { useEscapeLayer } from "../escape"; +import { useKeyContext } from "../keys/keymap"; +import { useUpdate } from "../store/useUpdate"; +import { dismissUpdate, installUpdate } from "../update"; +import { Icon } from "./Icon"; + +/** One decimal from a megabyte up, none below it, so the number under the bar stops twitching. */ +function bytes(count: number): string { + if (count < 1024) return `${count} B`; + if (count < 1024 * 1024) return `${Math.round(count / 1024)} kB`; + return `${(count / (1024 * 1024)).toFixed(1)} MB`; +} + +export function UpdateDialog() { + const phase = useUpdate((s) => s.phase); + const version = useUpdate((s) => s.version); + const notes = useUpdate((s) => s.notes); + const downloaded = useUpdate((s) => s.downloaded); + const total = useUpdate((s) => s.total); + const error = useUpdate((s) => s.error); + + const installRef = useRef(null); + + const open = + phase === "available" || phase === "downloading" || phase === "installing" || phase === "error"; + const busy = phase === "downloading" || phase === "installing"; + + useEffect(() => { + if (phase === "available") installRef.current?.focus(); + }, [phase]); + + useEscapeLayer(open && !busy, dismissUpdate); + useKeyContext("overlay", open); + + if (!open) return null; + + const percent = total === null || total === 0 ? null : Math.min(100, (downloaded / total) * 100); + + return ( +
+
e.stopPropagation()} + > +
+

{version === null ? "Update" : `Margin Docs ${version}`}

+ {!busy && ( + + )} +
+ +
+ {phase === "error" ? ( +

{error ?? "The update could not be installed."}

+ ) : ( + <> +

+ A new version is ready. Margin Docs will restart once it is installed, and anything + unsaved is written to disk first. +

+ {notes !== null && notes.trim() !== "" && ( +
+ {notes.trim()} +
+ )} + + )} + + {busy && ( +
+
+
+
+ + {phase === "installing" + ? "Installing…" + : total === null + ? `${bytes(downloaded)} downloaded` + : `${bytes(downloaded)} of ${bytes(total)}`} + +
+ )} +
+ +
+ {phase === "error" ? ( + + ) : ( + <> + {!busy && ( + + )} + + + )} +
+
+
+ ); +} diff --git a/src/dev/mockIpc.ts b/src/dev/mockIpc.ts index 16fcf4d..86e2119 100644 --- a/src/dev/mockIpc.ts +++ b/src/dev/mockIpc.ts @@ -19,6 +19,7 @@ import type { AssetResult, Backlink, FileNode, + GrammarIssue, IndexStatus, MatchRange, QuickOpenHit, @@ -210,6 +211,47 @@ const DEV_MISSPELLINGS: Record = { /** Words `spell_learn` was told about this session. The real checker teaches the whole machine. */ const devLearned = new Set(); +/** + * Grammar in a browser is not Harper either. It is a handful of patterns, which is enough to put a + * real underline under a real phrase, open a real popover and take a real correction. The rule + * names are Harper's own, so the popover shows what it will show in the app. + */ +const DEV_GRAMMAR: ReadonlyArray<{ pattern: RegExp; kind: string; message: string; fix: string }> = [ + { + pattern: /\bthe the\b/gi, + kind: "Repetition", + message: "Repeated word.", + fix: "the", + }, + { + pattern: /\bshould of\b/gi, + kind: "WordChoice", + message: "“Should of” is not a phrase; “should have” is.", + fix: "should have", + }, + { + pattern: /\bthere own\b/gi, + kind: "WordChoice", + message: "“There” is a place. The possessive is “their”.", + fix: "their own", + }, + { + pattern: /\bis are\b/gi, + kind: "Agreement", + message: "Two verbs where one belongs.", + fix: "are", + }, +]; + +/** Set to keep the dev fixture from claiming a Writing Tools menu the browser plainly has not got. */ +const noWritingTools = (): boolean => { + try { + return localStorage.getItem("margindocs-dev-no-writing-tools") === "1"; + } catch { + return false; + } +}; + export async function mockCall(command: string, args?: Record): Promise { const a = (args ?? {}) as Record; switch (command) { @@ -480,6 +522,47 @@ export async function mockCall(command: string, args?: Record x.start - y.start); + return issues as unknown as T; + } + + // Writing Tools has nothing behind it in a browser: there is no Edit menu to walk and no system + // to rewrite anything. What the fixture can serve is the two answers the UI has to handle, and + // which one it gives is a storage key, so a test can drive either. + case "writing_available": + return !noWritingTools() as unknown as T; + + case "writing_run": + return undefined as T; + + // A PDF the fixture cannot compile, and a header is enough: what the browser suite asserts about + // an export is that it asked, that it wrote nothing to the document, and that the bytes went to + // the panel's path. None of that needs a typesetter. + case "pdf_compile": + return new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]).buffer as unknown as T; + + case "pdf_write": + return undefined as T; + default: throw new Error(`dev mock has no handler for ${command}`); } diff --git a/src/editor/proofing.ts b/src/editor/proofing.ts index 092702b..d189c53 100644 --- a/src/editor/proofing.ts +++ b/src/editor/proofing.ts @@ -1,4 +1,4 @@ -// Spelling, drawn over the document as decorations and never as an edit to it. +// Spelling and grammar, drawn over the document as decorations and never as an edit to it. // // The whole of this file is paint and a menu. The only transaction in it that carries a step is the // one a user makes by choosing a suggestion, and that one is a single transaction so a single undo @@ -8,14 +8,29 @@ // rewrites text on its own would be a file damaging bug in this project's terms, so it does not have // the ability to. // +// TWO CHECKERS, ONE PIPELINE. +// +// Spelling is the system's and grammar is Harper's. They are separate settings, separate commands +// and separate answers, and they share everything between the document and the underline: the same +// blocks, the same batching, the same debounce, the same decoration set and the same popover. There +// is deliberately no second producer of strings and no second pass. The guard below is `proseBlocks` +// and it is one function; a guard that only one of the two checkers happened to go through is a +// guard that stops being there the day somebody adds a third call beside them. +// +// The caches are the one thing that is per checker, because the answers are. A block can have been +// asked about by one and not the other, which is exactly what the first launch after somebody turns +// grammar on looks like, and a single cache would either re-ask the whole document on every toggle +// or claim a block was checked for something it was never sent to. +// // WHAT IS CHECKED, and where that guard actually sits. // // Prose only. A fenced code block, a raw block, a maths field and an inline code span are not prose, // and underlining somebody's variable names is the fastest way to get a spell checker turned off for // good. The guard is `proseBlocks` below, and it is the single producer of every string this file -// sends: `pass` checks only what `proseBlocks` returned, `spellCheck` is called from nowhere else in -// the app outside src/api/spell.ts itself, and a block that never went to the checker has no entry -// in the cache and therefore no decoration. There is no second path to the checker to forget to +// sends to either checker: `pass` checks only what `proseBlocks` returned, `spellCheck` and +// `grammarCheck` are called from nowhere else in the app outside src/api/spell.ts and +// src/api/grammar.ts themselves, and a block that never went to a checker has no entry in that +// checker's cache and therefore no decoration. There is no second path to either one to forget to // guard, which is the shape of guard this project has now shipped four times unreached. // // A URL is the exception and it is deliberately not handled here. The Rust side asks AppKit to @@ -24,12 +39,13 @@ // // WHEN IT RUNS. // -// Not on every keystroke. Each check crosses the IPC boundary into AppKit, so typing schedules a -// pass a few hundred milliseconds out and every further keystroke pushes it back. A pass sends only -// the blocks whose text the checker has not already answered about, so an ordinary edit is one -// paragraph over the wire and a document nobody has touched is nothing at all. The cache is keyed by -// the exact text of a block, which is what makes that true without any range tracking: text the user -// has not touched is text that is still its own key. +// Not on every keystroke. Each check crosses the IPC boundary, into AppKit for spelling and into +// Harper's linter for grammar, so typing schedules a pass a few hundred milliseconds out and every +// further keystroke pushes it back. A pass sends only the blocks whose text a checker has not +// already answered about, so an ordinary edit is one paragraph over the wire and a document nobody +// has touched is nothing at all. A cache is keyed by the exact text of a block, which is what makes +// that true without any range tracking: text the user has not touched is text that is still its own +// key. // // AND WHY A STALE ANSWER CANNOT LAND. // @@ -46,8 +62,9 @@ import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import type { EditorState, PluginView } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import type { EditorView } from "@tiptap/pm/view"; +import { grammarCheck } from "../api/grammar"; import { spellCheck } from "../api/spell"; -import type { SpellIssue } from "../ipc"; +import type { GrammarIssue, SpellIssue } from "../ipc"; import { useProofing, type ProofTarget } from "../store/useProofing"; /** How long after the last keystroke a pass runs. Long enough that typing a word is one check. */ @@ -79,6 +96,18 @@ interface Block { text: string; } +/** + * The half of an answer this file's plumbing cares about, which is the same half for both checkers. + * + * Everything from the run batching to the cache to the offset arithmetic is about where a problem is + * and nothing about what it is, so it is written once against this and used twice, rather than + * copied and left to drift apart a fix at a time. + */ +interface Ranged { + start: number; + end: number; +} + /** Several blocks' text in one string, and where each of them starts in it. */ interface Run { text: string; @@ -86,14 +115,15 @@ interface Run { } /** - * What the checker has already said, keyed by the exact text it was said about. + * What each checker has already said, keyed by the exact text it was said about. * * Module level rather than per view because it is not about a document: two files that share a - * paragraph share its answer, and switching away from a document and back does not re-ask anything. - * It is pruned at the end of every pass down to the text that is actually in the document, so it - * cannot grow past the size of what is open. + * paragraph share its answers, and switching away from a document and back does not re-ask anything. + * Both are pruned at the end of every pass down to the text that is actually in the document, so + * neither can grow past the size of what is open. */ -let cache = new Map(); +const spellCache = new Map(); +const grammarCache = new Map(); /** * The view a menu acts on. There is one editor and one document (see src/editor/index.ts), so there @@ -156,8 +186,8 @@ function proseBlocks(doc: ProseMirrorNode): Block[] { return blocks; } -/** The distinct texts in the document the checker has not answered about yet. */ -function pending(blocks: readonly Block[]): string[] { +/** The distinct texts in the document one checker has not answered about yet. */ +function pending(cache: ReadonlyMap, blocks: readonly Block[]): string[] { const seen = new Set(); const out: string[] = []; for (const block of blocks) { @@ -193,13 +223,18 @@ function runsOf(texts: readonly string[]): Run[] { * next pass would send again, so a paragraph the checker is happy with has to be recorded as * checked or the document with no misspellings in it is the one that never stops asking. */ -function store(run: Run, issues: readonly SpellIssue[]): void { - const byPart = run.parts.map(() => [] as SpellIssue[]); +function store( + cache: Map, + run: Run, + issues: readonly T[], +): void { + const byPart = run.parts.map(() => [] as T[]); for (const issue of issues) { const index = run.parts.findIndex( (part) => issue.start >= part.at && issue.end <= part.at + part.text.length, ); - // An issue that reaches across the join between two blocks is not a word in either of them. + // An issue that reaches across the join between two blocks is not about either of them: a + // misspelling is not a word there, and a sentence is not one sentence there. if (index === -1) continue; const part = run.parts[index]; byPart[index].push({ ...issue, start: issue.start - part.at, end: issue.end - part.at }); @@ -207,47 +242,88 @@ function store(run: Run, issues: readonly SpellIssue[]): void { run.parts.forEach((part, index) => cache.set(part.text, byPart[index])); } -/** Down to what is in the document, so the cache is never larger than what is open. */ -function prune(blocks: readonly Block[]): void { - const kept = new Map(); - for (const block of blocks) { - const issues = cache.get(block.text); - if (issues) kept.set(block.text, issues); +/** + * Down to what is in the document, so a cache is never larger than what is open. + * + * In place rather than by building a smaller map, so that the two maps below are the same two + * objects for the life of the module. A pass hands one of them to `store` and then waits on the IPC + * boundary, and a second pass can finish and prune in the meantime; if pruning swapped the map, the + * first pass would come back and file its answers into one nothing reads any more. + */ +function prune(cache: Map, blocks: readonly Block[]): void { + const alive = new Set(blocks.map((block) => block.text)); + for (const text of cache.keys()) { + if (!alive.has(text)) cache.delete(text); } - cache = kept; +} + +/** Which underlines are wanted, and what has been waved away for this sitting. */ +interface Drawing { + spelling: boolean; + grammar: boolean; + ignored: ReadonlySet; } /** - * The decorations for the document as it stands, built from what the checker has already said. + * Whether an issue's offsets sit inside the block it claims to be about. + * + * A checker is a foreign process walking the user's text, and a decoration running past the end of + * a node throws inside the view rather than merely looking wrong, so an answer that does not fit is + * dropped rather than clamped: an underline in the wrong place is a claim about the wrong words. + */ +function inside(issue: Ranged, block: Block): boolean { + return issue.start >= 0 && issue.end > issue.start && issue.end <= block.text.length; +} + +/** + * The decorations for the document as it stands, built from what the checkers have already said. * * A block with nothing cached contributes nothing, which is what an underline that has not arrived - * yet looks like, and an issue whose offsets do not sit inside the block they claim to be about is - * dropped: the checker is a foreign process walking the user's text, and a decoration running past - * the end of a node throws inside the view rather than merely looking wrong. + * yet looks like. The two kinds are built in the same set and can overlap: a misspelled word inside + * a phrase Harper does not like gets both marks, and `menuAt` below decides which of them a click + * is about. */ -function decorationsFor( - doc: ProseMirrorNode, - blocks: readonly Block[], - ignored: ReadonlySet, -): DecorationSet { +function decorationsFor(doc: ProseMirrorNode, blocks: readonly Block[], what: Drawing): DecorationSet { const decorations: Decoration[] = []; for (const block of blocks) { - const issues = cache.get(block.text); - if (!issues) continue; - for (const issue of issues) { - if (issue.start < 0 || issue.end <= issue.start || issue.end > block.text.length) continue; - if (ignored.has(issue.word.toLowerCase())) continue; - decorations.push( - Decoration.inline( - block.base + issue.start, - block.base + issue.end, - { class: "proof-mark" }, - // The word and its suggestions ride on the decoration rather than in a list beside it, so - // that mapping the set through an edit keeps the menu's offer attached to the word it was - // about instead of to a position that has moved. - { word: issue.word, suggestions: issue.suggestions }, - ), - ); + if (what.spelling) { + for (const issue of spellCache.get(block.text) ?? []) { + if (!inside(issue, block) || what.ignored.has(issue.word.toLowerCase())) continue; + decorations.push( + Decoration.inline( + block.base + issue.start, + block.base + issue.end, + { class: "proof-mark" }, + // The word and its suggestions ride on the decoration rather than in a list beside it, + // so that mapping the set through an edit keeps the menu's offer attached to the word it + // was about instead of to a position that has moved. + { word: issue.word, suggestions: issue.suggestions, grammar: null }, + ), + ); + } + } + if (what.grammar) { + for (const issue of grammarCache.get(block.text) ?? []) { + if (!inside(issue, block)) continue; + // Harper answers about a span rather than about a word, so what the popover is "about" is + // whatever that span covers. It is taken from the block's own text for the same reason the + // spelling half takes the word the checker sent back: it is what a suggestion will be + // checked against before anything is written. + const text = block.text.slice(issue.start, issue.end); + if (what.ignored.has(text.toLowerCase())) continue; + decorations.push( + Decoration.inline( + block.base + issue.start, + block.base + issue.end, + { class: "proof-mark-grammar" }, + { + word: text, + suggestions: issue.suggestions, + grammar: { kind: issue.kind, message: issue.message }, + }, + ), + ); + } } } return DecorationSet.create(doc, decorations); @@ -263,41 +339,79 @@ function clear(view: EditorView): void { draw(view, DecorationSet.empty); } +/** What is turned on right now, and available to be turned on at all. */ +function wanted(): Drawing { + const state = useProofing.getState(); + return { + spelling: state.enabled && state.availability === "ready", + grammar: state.grammar && state.grammarAvailability === "ready", + ignored: state.ignored, + }; +} + /** - * One check of whatever the checker has not seen, and then a redraw. + * One check of whatever the checkers have not seen, and then a redraw. * * The decorations are built from `view.state.doc` after the awaits rather than from the document the * pass started on. By then the sequence number has already answered whether anything moved, so the * two agree; building from what is on screen is what makes that a fact about the code rather than a * fact about the timing. + * + * Spelling first and grammar after, rather than both at once. The two calls are cheap and the user + * is typing while they run, so the useful thing is that the commoner of the two answers first and + * its underlines land while the other is still being asked, not that the pair finishes a few + * milliseconds sooner. */ async function pass(view: EditorView): Promise { const seq = passSeq; - const state = useProofing.getState(); - if (!state.enabled || state.availability !== "ready") { + const what = wanted(); + if (!what.spelling && !what.grammar) { clear(view); return; } - for (const run of runsOf(pending(proseBlocks(view.state.doc)))) { - try { - store(run, await spellCheck(run.text)); - } catch { - // The checker went away mid document, which on a build without one is what the first call - // does. Nothing is drawn and nothing is said: what is already underlined stays, and the next - // edit asks again. - return; + const blocks = proseBlocks(view.state.doc); + // A checker that is not there, which on a build without one is what the first call finds out, + // ends its own half of the pass and says nothing. There is no toast: "this build cannot check + // grammar" is what the availability question the store asks once per launch is for, and not + // something worth repeating on every keystroke. The other checker's loop is untouched, which is + // why each has its own catch rather than the pair sharing one, and the redraw below still happens + // from whatever the two of them had already answered. + if (what.spelling) { + for (const run of runsOf(pending(spellCache, blocks))) { + try { + store(spellCache, run, await spellCheck(run.text)); + } catch { + break; + } + } + } + if (what.grammar) { + for (const run of runsOf(pending(grammarCache, blocks))) { + try { + store(grammarCache, run, await grammarCheck(run.text)); + } catch { + break; + } } } if (seq !== passSeq || view.isDestroyed) return; - const blocks = proseBlocks(view.state.doc); - draw(view, decorationsFor(view.state.doc, blocks, useProofing.getState().ignored)); - prune(blocks); + const current = proseBlocks(view.state.doc); + draw(view, decorationsFor(view.state.doc, current, wanted())); + prune(spellCache, current); + prune(grammarCache, current); +} + +/** Harper's half of a decoration's spec, and null for a spelling one. */ +function grammarOf(value: unknown): ProofTarget["grammar"] { + if (typeof value !== "object" || value === null) return null; + const { kind, message } = value as { kind?: unknown; message?: unknown }; + return typeof kind === "string" && typeof message === "string" ? { kind, message } : null; } /** - * Puts the menu over one underlined word, whichever of the three ways in found it. + * Puts the menu over one underlined run of text, whichever of the three ways in found it. * * `fromKeyboard` is the only thing that separates a chord from a pointer once the word is known, * and the menu reads it to decide whether to take focus. A click has already put the caret where @@ -306,7 +420,7 @@ async function pass(view: EditorView): Promise { * offered. */ function openFor(view: EditorView, hit: Decoration, fromKeyboard: boolean): boolean { - const spec = hit.spec as { word?: unknown; suggestions?: unknown }; + const spec = hit.spec as { word?: unknown; suggestions?: unknown; grammar?: unknown }; if (typeof spec.word !== "string") return false; const start = view.coordsAtPos(hit.from); @@ -319,6 +433,7 @@ function openFor(view: EditorView, hit: Decoration, fromKeyboard: boolean): bool 0, MAX_SUGGESTIONS, ), + grammar: grammarOf(spec.grammar), left: (start.left + end.left) / 2, top: start.top, // A word that wraps across two lines ends on the lower one, which is where the menu belongs. @@ -328,7 +443,7 @@ function openFor(view: EditorView, hit: Decoration, fromKeyboard: boolean): bool return true; } -/** The misspelling under a position, if the menu should open over one. */ +/** The underlined text under a position, if the menu should open over it. */ function menuAt(view: EditorView, pos: number): boolean { // A document held open while a conflict is resolved is one nothing may edit, and a menu whose // every item is an edit has nothing to offer there. The underlines stay; the menu does not open. @@ -337,14 +452,20 @@ function menuAt(view: EditorView, pos: number): boolean { if (!decorations) return false; const found = decorations.find(pos, pos); if (found.length === 0) return false; - // A position at the seam between two words touches both, so a hit that actually contains it wins. - const hit = found.find((deco) => deco.from < pos && pos < deco.to) ?? found[0]; + // A position at the seam between two words touches both, so hits that actually contain it win. + // Among those, the shortest: a misspelled word inside a phrase Harper flagged carries both marks, + // and a click on that word is about the word. The phrase is still one click away, on any of the + // characters the word does not cover. + const containing = found.filter((deco) => deco.from < pos && pos < deco.to); + const hit = (containing.length > 0 ? containing : found).reduce((best, deco) => + deco.to - deco.from < best.to - best.from ? deco : best, + ); return openFor(view, hit, false); } /** - * The same menu, opened by a chord instead of by a pointer, over the misspelling at the caret or - * the nearest one to it. + * The same menu, opened by a chord instead of by a pointer, over the underlined text at the caret + * or the nearest to it, of either kind. * * Nearest within the caret's own paragraph and no further. The menu is placed at the viewport * coordinates of the word it is about, so a search that ran to the end of the document would open a @@ -397,12 +518,16 @@ class Proofreader implements PluginView { this.unsubscribe = useProofing.subscribe((next, previous) => { // A learned word changes the answer for text nobody has touched, so it is the one thing that - // throws away what the checker already said. Ignoring a word does not: it is filtered when the - // decorations are built, so putting it back costs nothing. - if (next.revision !== previous.revision) cache.clear(); + // throws away what a checker already said. Only the spelling half: Harper's own spell rule is + // off (see src-tauri/src/grammar.rs), so the system dictionary growing a word cannot change a + // single thing it said. Ignoring a word throws away nothing either way, since it is filtered + // when the decorations are built and putting it back costs nothing. + if (next.revision !== previous.revision) spellCache.clear(); if ( next.enabled === previous.enabled && next.availability === previous.availability && + next.grammar === previous.grammar && + next.grammarAvailability === previous.grammarAvailability && next.ignored === previous.ignored && next.revision === previous.revision ) { @@ -441,18 +566,25 @@ class Proofreader implements PluginView { } /** - * Puts a suggestion in place of the word the menu was opened over. + * Puts a suggestion in place of the text the menu was opened over, a word for spelling and a phrase + * for grammar. * - * One transaction, so one undo takes it back, and refused outright unless the word is still exactly + * One transaction, so one undo takes it back, and refused outright unless that text is still exactly * where the menu said it was. The menu closes on every document change, so that check should never * fail; it is here because this is the one function in the file that can write to somebody's * document, and it is worth being unable to write to the wrong part of it. * + * The one case where it refuses something a user meant is a grammar span reaching across an inline + * code span. `blockText` blanked the code into spaces and the document has the code itself there, so + * the two do not match and nothing is written. That is the right way round: a phrase Harper judged + * without being shown the code in the middle of it is a phrase whose correction would have eaten the + * code, and refusing costs a gesture where writing it would cost the line. + * * No `fits` guard, unlike every insert in src/editor/Editor.tsx, and for the reason src/editor/ * fits.test.ts gives the find bar's replace: this is text going into the one textblock it came out * of, not a node being placed somewhere it may not go. The range is inside a block that `proseBlocks` - * already refused to send if it was code or raw, and a suggestion is one word, so there is no blank - * line in it for `holdsText` to be about. + * already refused to send if it was code or raw, and a suggestion is a few words at most, so there + * is no blank line in it for `holdsText` to be about. */ export function replaceSpelling(target: ProofTarget, suggestion: string): void { const view = activeView; diff --git a/src/editor/writing.ts b/src/editor/writing.ts new file mode 100644 index 0000000..6cb2399 --- /dev/null +++ b/src/editor/writing.ts @@ -0,0 +1,173 @@ +// Writing Tools, from the editor's side. +// +// The system rewrites the selection by mutating the DOM under prosemirror-view, which reparses it. +// That is the same seam docs/architecture.md blames for the hard break bug, so this module is where +// the guard belongs: what may be handed to a rewrite, and what has to be refused before one starts. +// +// WHAT THE BYTES ACTUALLY DO, because none of the below is a guess. tests/writing-tools.spec.ts +// makes the mutation the system makes, in a running editor, and reads the file afterwards. +// +// Prose survives. A hand wrapped paragraph rewritten across its wraps comes back as one line and +// no backslash anywhere; a heading, a list item and a single table cell all keep their spelling; +// markdown characters that arrive in the new text are escaped, so a rewrite that opens with "1." or +// "-" stays the paragraph it was. A newline in the new text is a newline in the file, which is the +// paragraph's own contract, and a pipe inside a cell comes out `\|`. +// +// Structure does not. A selection with an end in each of two blocks is where every real failure +// was: two paragraphs became three with ` ` in the middle of the user's words, a heading and +// the paragraph under it became a truncated heading and an invented paragraph, two list items +// became three, and two table cells became a table one column wider whose rows disagree, which is +// not a table any more. So a rewrite gets one block, and it is refused rather than trimmed to fit: +// the selection is the user's and silently rewriting a different range than the one they made is +// its own bug. +// +// Inside one block, the danger is anything whose spelling is not in the words on screen. A range +// crossing a link is replaced by plain text and the address goes with it, invisibly, and the same +// gesture eats an inline picture. A range wholly inside a link is safe and stays allowed, because +// the mark is an ancestor of it rather than something it can delete. Code and formulae are refused +// the way src/editor/proofing.ts refuses them, and for its reason rather than a byte one: a fenced +// block, a raw block and a code span all round trip cleanly, and a tool that rewrites somebody's +// variable names is a tool they turn off. A raw block is the file's own bytes and the only correct +// thing to do with them is nothing. +// +// Nothing here writes to the document. The rewrite arrives as a DOM mutation, TipTap sees the +// transaction it becomes and the shell's debounce saves it, exactly as if the user had typed. +// +// AND THE SELECTION IS READ, NEVER PUT BACK. Writing Tools rewrites whatever holds the keyboard, so +// the document has to be holding it before one starts, and this refuses rather than reaching over +// and focusing the document itself. That is not squeamishness, it was measured: the command palette +// runs its row and then closes on to ``, and focusing the editor at that moment is what +// destroys the selection rather than what saves it, because prosemirror-view takes the collapsed +// selection left behind by the panel that just unmounted as the document's new one. A refusal that +// says which gesture to use costs the user one click. A rewrite aimed at a caret it moved itself +// costs them a paragraph. + +import { writingAvailable, writingRun } from "../api/writing"; +import { notify } from "../store/useToast"; + +/** The two items this app puts on the menu, named as the system's own submenu names them. */ +const TITLES = { proofread: "Proofread", rewrite: "Rewrite" } as const; + +export type WritingTool = keyof typeof TITLES; + +const UNAVAILABLE = + "Writing Tools needs macOS 15.1 or later with Apple Intelligence turned on."; + +/** The two editables a document is ever open in. Exactly one of them is on screen at a time. */ +const MARKDOWN = ".prose"; +const PLAIN = "textarea.plain-text"; + +/** + * Every block a selection's two ends are measured against, prose or not. The ones that may not be + * rewritten are in the list on purpose: a range inside a fence has to resolve to the fence so the + * refusal can name it, rather than resolving to nothing and being reported as spanning blocks. + */ +const BLOCKS = "p, h1, h2, h3, h4, h5, h6, td, th, pre, [data-math-block]"; + +/** What a range inside one prose block may not contain, and what a partly covered one clones as. */ +const FRAGILE = "a, code, img, .math-inline"; + +const REFUSED = { + closed: "Open a document first.", + unfocused: + "Writing Tools rewrites whatever holds the keyboard. Click into the document, select the text you want changed, and use the Edit menu.", + empty: "Select the text you want Writing Tools to change first.", + blocks: + "Writing Tools rewrites one paragraph at a time, and that selection covers more than one.", + raw: "That block is the file's own bytes, kept exactly as they were read, so Writing Tools is not offered it.", + code: "Writing Tools is not offered code.", + math: "Writing Tools is not offered a formula.", + summary: "Writing Tools is not offered a toggle's summary.", + link: "That selection covers a link, and a rewrite would take its address with it. Select the words either side instead.", + image: "That selection covers a picture, and a rewrite would take it out of the document.", +} as const; + +function elementOf(node: Node | null): Element | null { + if (!node) return null; + return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; +} + +/** The block a range end is in, or null when it is somewhere with no block under it at all. */ +const blockOf = (node: Node): Element | null => elementOf(node)?.closest(BLOCKS) ?? null; + +/** Why this block may not be rewritten, or null when it is prose. */ +function blockRefusal(block: Element): string | null { + if (block.matches("pre[data-raw]")) return REFUSED.raw; + if (block.matches("pre")) return REFUSED.code; + if (block.matches("[data-math-block]")) return REFUSED.math; + return null; +} + +/** + * Why the run inside one prose block may not be rewritten, or null when it may. + * + * Two questions, not one. `cloneContents` reports what the range covers, including an element it + * covers only part of, which is the case that destroys a link. It says nothing about an element the + * range sits wholly inside, since that is an ancestor rather than content, so the marks around the + * range are asked for separately. + */ +function contentRefusal(range: Range): string | null { + const around = elementOf(range.commonAncestorContainer); + if (around?.closest("code")) return REFUSED.code; + if (around?.closest(".math-inline")) return REFUSED.math; + + const covered = range.cloneContents().querySelector(FRAGILE); + if (!covered) return null; + if (covered.matches("code")) return REFUSED.code; + if (covered.matches("img")) return REFUSED.image; + if (covered.matches(".math-inline")) return REFUSED.math; + return REFUSED.link; +} + +/** Why the selection on screen may not be handed to a rewrite, or null when it may. */ +function selectionRefusal(): string | null { + const surface = document.querySelector(`${MARKDOWN}, ${PLAIN}`); + if (!surface) return REFUSED.closed; + + const active = document.activeElement; + + // A .txt file is a textarea with no markdown in it to lose, so there is nothing here to guard + // beyond the document holding the keyboard with something selected in it. + if (surface.matches(PLAIN)) { + const field = surface as HTMLTextAreaElement; + if (active !== field) return REFUSED.unfocused; + return field.selectionStart === field.selectionEnd ? REFUSED.empty : null; + } + + // A formula's source field and a toggle's title are editables of their own nested inside the + // document, so each takes the keyboard away from it. Neither is prose in a block of the file, and + // saying which one the cursor is in is worth more than the general answer below. + if (active && active !== surface && surface.contains(active)) { + if (active.closest("[data-math-block]")) return REFUSED.math; + if (active.closest("[data-toggle-summary]")) return REFUSED.summary; + return REFUSED.unfocused; + } + if (active !== surface) return REFUSED.unfocused; + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return REFUSED.empty; + const range = selection.getRangeAt(0); + if (range.collapsed || !surface.contains(range.commonAncestorContainer)) return REFUSED.empty; + + const block = blockOf(range.startContainer); + if (!block || block !== blockOf(range.endContainer)) return REFUSED.blocks; + + return blockRefusal(block) ?? contentRefusal(range); +} + +export async function runWritingTool(tool: WritingTool): Promise { + try { + if (!(await writingAvailable())) { + notify(UNAVAILABLE); + return; + } + const refusal = selectionRefusal(); + if (refusal) { + notify(refusal); + return; + } + await writingRun(TITLES[tool]); + } catch (e) { + notify(`Could not run Writing Tools: ${String(e)}`); + } +} diff --git a/src/export/run.ts b/src/export/run.ts new file mode 100644 index 0000000..f5035da --- /dev/null +++ b/src/export/run.ts @@ -0,0 +1,173 @@ +// Running an export: the document on screen becomes Typst source, the source becomes PDF bytes, +// and the bytes go wherever the native save panel points. +// +// Nothing in here writes the user's markdown or touches the open buffer. An export of a document +// with unsaved edits exports what is on screen, and leaves the file alone: this module never calls +// `save`, never dispatches a transaction, and never asks the document store for anything but the +// tree it is already holding. That is not an accident of the current code, it is the requirement. +// A save before an export would mean choosing to export a document turns into writing over a file +// the user had not decided to write yet, and there is no undo for that on disk. + +import { listen } from "@tauri-apps/api/event"; +import { save as savePanel } from "@tauri-apps/plugin-dialog"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { pdfCompile, pdfWrite } from "../api/pdf"; +import { PDF_WARNINGS_EVENT, isDesktop, isTauri, type PdfWarning } from "../ipc"; +import { useDocument } from "../store/useDocument"; +import { notify } from "../store/useToast"; +import { diagramSources, documentToTypst, renderDiagrams, type Diagrams, type TypstDocument } from "./typst"; + +type Phase = "idle" | "converting" | "compiling" | "writing"; + +/** + * Where the export has got to. A phase rather than a boolean because there are four answers and + * only one of them is "nothing is happening", and because the second Cmd-E while a fifty page + * document is compiling has to be refused rather than queued behind the first. + */ +let phase: Phase = "idle"; + +const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path; + +/** The same name with a .pdf on it, which is what the save panel opens with. */ +function suggestedName(path: string): string { + const name = baseName(path); + const dot = name.lastIndexOf("."); + return `${dot > 0 ? name.slice(0, dot) : name}.pdf`; +} + +function describe(warnings: readonly PdfWarning[]): string { + const total = warnings.reduce((sum, warning) => sum + Math.max(1, warning.count), 0); + const first = warnings[0]?.message ?? ""; + if (total <= 1) return first; + return `${first} (and ${total - 1} more)`; +} + +/** + * Warnings arrive on an event rather than with the bytes, so the listener has to be up before the + * compile starts and down again after it. Anything that arrives outside that window belongs to + * somebody else's export and is none of this call's business. + */ +async function withWarnings(work: () => Promise): Promise<{ result: T; warnings: PdfWarning[] }> { + const warnings: PdfWarning[] = []; + // In a browser there is no Tauri event bus to listen on. The dev fixture answers the two commands + // so the path can be walked, and it has no warnings to send. + const unlisten = isTauri + ? await listen(PDF_WARNINGS_EVENT, (event) => { + if (Array.isArray(event.payload)) warnings.push(...event.payload); + }) + : null; + try { + return { result: await work(), warnings }; + } finally { + unlisten?.(); + } +} + +/** Whether a document has anything for the maths package to choke on. */ +function hasMath(doc: ProseMirrorNode): boolean { + let found = false; + doc.descendants((node) => { + if (node.type.name === "mathInline" || node.type.name === "mathBlock") found = true; + return !found; + }); + return found; +} + +/** + * The compile, and the one retry worth making. + * + * mitex fails a compile rather than a formula: LaTeX it cannot parse is a hard error out of the + * plugin, and there is no try in Typst to put around it. So a document whose maths will not typeset + * would otherwise cost the whole PDF, over one expression, with no way for the author to tell which. + * The second attempt writes every formula as the monospace source the editor already shows on + * screen, which is a page the author can read and act on rather than a failure they cannot. + * + * Only for a document that actually has a formula in it. Anything else that fails to compile failed + * for a reason a second identical compile will not fix. + * + * This is the second line and not the first. src-tauri/src/pdf.rs already retries a compile that + * mitex brought down, by serving a stand-in library that sets each formula as the source it was + * written in, and that catches almost everything: reaching here means even that failed. The two + * are worth having separately because they fail differently. That one still goes through mitex's + * own files; this one regenerates the source without asking mitex anything at all. + */ +async function compileWithFallback( + doc: ProseMirrorNode, + path: string, + diagrams: Diagrams, + first: TypstDocument, +): Promise<{ bytes: ArrayBuffer; warnings: PdfWarning[]; retried: boolean }> { + try { + const attempt = await withWarnings(() => pdfCompile(first.source, first.images)); + return { bytes: attempt.result, warnings: attempt.warnings, retried: false }; + } catch (e) { + if (!hasMath(doc)) throw e; + const literal = documentToTypst(doc, path, { diagrams, math: "literal" }); + const attempt = await withWarnings(() => pdfCompile(literal.source, literal.images)); + return { bytes: attempt.result, warnings: attempt.warnings, retried: true }; + } +} + +/** + * The whole export, from the tree in the editor to a file on disk. + * + * Bound to the `export-pdf` command in src/keys/commands.ts, which is the only caller. + */ +export async function exportPdf(): Promise { + if (phase !== "idle") { + notify("An export is already running."); + return; + } + + const { path, content } = useDocument.getState(); + if (path === null || content === null) { + notify("Open a document to export it."); + return; + } + // The save panel is a window API, so it is not merely absent in a browser, it is refused. There + // is no point compiling a PDF this build has nowhere to put. + if (!isDesktop) { + notify("PDF export needs the desktop app."); + return; + } + + phase = "converting"; + try { + // Drawn before the conversion rather than during it, because mermaid is asynchronous and wants + // a DOM, and the converter is neither. A diagram that will not draw is simply missing from the + // map and comes out of the converter as the fence it always was. + const drawings = await renderDiagrams(content); + const undrawn = diagramSources(content).length - drawings.size; + + const converted = documentToTypst(content, path, { diagrams: drawings }); + + phase = "compiling"; + const { bytes, warnings, retried } = await compileWithFallback(content, path, drawings, converted); + + phase = "writing"; + const target = await savePanel({ + title: "Export as PDF", + defaultPath: suggestedName(path), + filters: [{ name: "PDF", extensions: ["pdf"] }], + }); + if (target === null) return; + + await pdfWrite(target, new Uint8Array(bytes)); + + const trouble = [ + ...converted.warnings, + ...(undrawn > 0 ? [`${undrawn} diagram${undrawn === 1 ? "" : "s"} could not be drawn`] : []), + ...(retried ? ["every formula shown as the source it was written in"] : []), + ...(warnings.length > 0 ? [describe(warnings)] : []), + ]; + notify( + trouble.length === 0 + ? `Exported ${baseName(target)}` + : `Exported ${baseName(target)}, with ${trouble.join("; ")}`, + ); + } catch (e) { + notify(`Could not export: ${String(e)}`); + } finally { + phase = "idle"; + } +} diff --git a/src/export/typst.test.ts b/src/export/typst.test.ts new file mode 100644 index 0000000..b4ddea9 Binary files /dev/null and b/src/export/typst.test.ts differ diff --git a/src/export/typst.ts b/src/export/typst.ts new file mode 100644 index 0000000..c13b0ee --- /dev/null +++ b/src/export/typst.ts @@ -0,0 +1,773 @@ +// The ProseMirror document to Typst converter. +// +// Every node in src/model/schema.ts needs an answer here, and escaping is a security boundary +// rather than a formatting detail: a document containing `#let`, `$` or `@` must reach the +// compiler as text and never as Typst. +// +// The whole defence is one rule, and it is the reason this file has no `esc` that sprinkles +// backslashes through markup the way the sibling book exporter does. Nothing the document holds is +// ever written into markup. Every character that came out of a user's file leaves this module +// through `str`, which produces a Typst *string literal*, and a string literal has exactly five +// escapes and no syntax inside it: `#"= #let x $y$ @z"` is eight words on a page and cannot be +// anything else. So the grep that proves the boundary is short. If a template literal below +// interpolates a value that did not come from `str`, from `hex` or from a constant declared in this +// file, that is the bug. +// +// The corollary is the shape of the output. Inline content is a run of `#`-prefixed expressions +// with nothing between them, because a string literal followed by a bare `[` or `(` would be read +// as a call and a string literal followed by a space would put a space on the page. Blocks are +// single expressions joined by blank lines. Anything that needs real layout is a `#let` in the +// preamble taking its text as a parameter, so the preamble is the only markup in the file and it +// is written here rather than derived from anything. + +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { ImageInput } from "../ipc"; +import { resolveRelative } from "../links"; +import { CALLOUT_LABELS, calloutKindFromLabel, type CalloutKind } from "../model/doc"; + +export interface TypstDocument { + source: string; + images: ImageInput[]; + /** What the converter worked around: an image with nowhere to read it from, a src it cannot use. */ + warnings: string[]; +} + +/** A mermaid fence's text, mapped to the SVG it drew. Missing means the fence stays code. */ +export type Diagrams = ReadonlyMap; + +/** + * How a formula is written out. + * + * `typeset` hands the LaTeX to mitex, which is the point of having maths at all. `literal` writes + * it as its own source in monospace, which is what the editor shows on screen and what an export + * falls back to when mitex will not take the document's LaTeX: mitex fails a compile rather than + * a formula, so one expression it cannot parse would otherwise cost the whole PDF. + */ +export type MathMode = "typeset" | "literal"; + +export interface TypstOptions { + diagrams?: Diagrams; + math?: MathMode; +} + +const MERMAID = "mermaid"; + +/** + * Where an image with no file behind it is put. + * + * The leading slash is load bearing. Typst resolves `image("x")` against the file that wrote it and + * the backend's resolver answers on the resolved path, so a relative name in a document compiled at + * the root would be looked up as `/x` and not found. Everything this module names is rooted, which + * is also true of the file-backed images: those carry the absolute path the backend reads them from. + */ +const INLINE = "/inline/"; + +// ------------------------------------------------------------------------------------------------ +// The boundary +// ------------------------------------------------------------------------------------------------ + +/** + * A lone surrogate is half of a character, and it survives neither the JSON of the IPC boundary nor + * UTF-8 on the other side of it. Nothing in a real document has one; a paste out of a broken tool + * does, and it must not be the thing that decides whether an export happens. + */ +function sanitize(value: string): string { + let out = ""; + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + out += code >= 0xd800 && code <= 0xdfff ? "�" : ch; + } + return out; +} + +/** + * The one place a string from the document becomes Typst source. + * + * A Typst string literal knows `\\`, `\"`, `\n`, `\r`, `\t` and `\u{...}` and nothing else, so a + * control character is written as its escape rather than passed through, and every other character + * stands for itself. There is no construct inside a string literal for a `#`, a `$` or an `@` to + * open, which is the whole point: the escaping here has one rule instead of a list of them, and a + * list is what goes out of date when Typst gains syntax. + */ +export function str(value: string): string { + let out = '"'; + for (const ch of sanitize(value)) { + const code = ch.codePointAt(0) ?? 0; + if (ch === "\\") out += "\\\\"; + else if (ch === '"') out += '\\"'; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + else if (code < 0x20 || code === 0x7f) out += `\\u{${code.toString(16)}}`; + else out += ch; + } + return `${out}"`; +} + +/** A colour from the table below, never from the document. */ +function hex(value: string): string { + return `rgb(${str(value)})`; +} + +/** + * Inline text as a page reads it rather than as the file stores it. + * + * A soft wrap inside a paragraph is a decision the author made about the file, not about the page, + * and a PDF has its own measure, so it becomes a space. Runs of whitespace collapse for the same + * reason every markdown renderer collapses them. A hard break is a separate node and keeps its + * break. The non-breaking space is deliberately not in the class: it is a character somebody typed. + */ +function flow(value: string): string { + return value.replace(/[ \t\n\r\f\v\u2028\u2029]+/g, " "); +} + +// ------------------------------------------------------------------------------------------------ +// The palette and the faces +// +// Both are the app's own, read off src/styles/tokens.css and src/styles/prose.css rather than +// chosen here, because an export is the document on screen and not a second design. The light +// palette specifically: a PDF is a white page whatever `data-theme` says. +// +// Literata and Hanken Grotesk are the two families src-tauri/src/pdf.rs compiles into the binary, +// so they are always there and can be named here. Monospace and the maths face are not named here +// at all: neither is bundled, both come off the machine, and Typst warns once for every family it +// was asked for and could not find, so a hopeful list written on this side would end every export +// with a toast about fonts nobody chose. src-tauri/src/fonts.rs asks the system what it actually +// has, and pdf.rs puts the answer in front of this preamble. +// ------------------------------------------------------------------------------------------------ + +const BODY_FONT = "Literata"; +const UI_FONT = "Hanken Grotesk"; + +// The light palette of src/styles/tokens.css, written out because Typst source cannot read a CSS +// custom property. Each one names the token it is a copy of, so a token that moves can be found +// again from here; nothing checks that they still agree, and a fork between the two is a PDF that +// stops looking like the screen it was exported from. +const INK = "#23201b"; // --ink +const INK_SOFT = "#6b6458"; // --ink-soft +const INK_FAINT = "#9b9484"; // --ink-faint +const DOC_RULE = "#e3ddce"; // --doc-rule +const DOC_RULE_STRONG = "#cdc4ae"; // --doc-rule-strong +const DOC_WASH = "#23201b0d"; // --ink at 5 percent +const CODE_SURFACE = "#f2ece0"; // --code-surface +const CODE_LINE = "#e6dfce"; // --code-line +const CODE_WASH = "#23201b14"; // --ink at 8 percent +const DANGER = "#b4453a"; // --danger +const DANGER_INK = "#963327"; // --danger-ink +const DANGER_WASH = "#b4453a1a"; // --danger at 10 percent + +/** + * The five callouts, in ink tones and the one warning colour rather than in five new hues. + * + * This is prose.css's decision, not a fresh one: the palette has a single danger colour and + * inventing four more here would fork the visual language between the screen and the page. + */ +const CALLOUT_COLOURS: Record = { + note: { edge: INK_FAINT, fill: DOC_WASH, label: INK_SOFT }, + tip: { edge: INK_FAINT, fill: DOC_WASH, label: INK_SOFT }, + important: { edge: INK_FAINT, fill: DOC_WASH, label: INK_SOFT }, + warning: { edge: DANGER, fill: DANGER_WASH, label: DANGER_INK }, + caution: { edge: DANGER, fill: DANGER_WASH, label: DANGER_INK }, +}; + +// ------------------------------------------------------------------------------------------------ +// The preamble +// ------------------------------------------------------------------------------------------------ + +/** + * The document's furniture, and the only markup in this file. + * + * A document editor, not a book: one page size, no chapter openers, no trim. + * + * `mitex` is imported only when the document has a formula in it. The import path is the contract + * with the backend, and a missing package is a hard compile error rather than a warning, so a + * document with no maths in it is not made to depend on it. + */ +function preamble(title: string, math: boolean): string { + const mitex = math ? `#import "/mitex/lib.typ": mitex, mi\n\n` : ""; + return `${mitex}#set document(title: ${str(title)}) +#set page(paper: "a4", margin: (x: 2.4cm, y: 2.6cm), numbering: "1", number-align: center) +#set text(font: ${str(BODY_FONT)}, size: 11pt, fill: ${hex(INK)}, lang: "en") +#set par(leading: 0.7em, spacing: 1.05em, justify: false) +#set heading(numbering: none) + +// Six levels do not come from six sizes: by the fourth step the difference is under a point and +// the reader stops seeing a hierarchy. Size for the first three, a weight step for the fourth, the +// UI face for the last two, case for the sixth. prose.css's scale, in print units. +#show heading: set block(above: 1.9em, below: 0.55em) +#show heading.where(level: 1): set text(size: 1.77em, weight: 600, tracking: -0.016em) +#show heading.where(level: 2): set text(size: 1.35em, weight: 600, tracking: -0.008em) +#show heading.where(level: 3): set text(size: 1.12em, weight: 600) +#show heading.where(level: 4): set text(size: 1em, weight: 700) +#show heading.where(level: 5): set text(font: ${str(UI_FONT)}, size: 0.88em, weight: 700) +#show heading.where(level: 6): set text( + font: ${str(UI_FONT)}, + size: 0.77em, + weight: 700, + tracking: 0.1em, + fill: ${hex(INK_SOFT)}, +) +#show heading.where(level: 6): upper + +// This palette has no link colour, because the book it came from had no links. A link is body text +// with a permanent underline instead: unmistakable in a scan, and it keeps the colour of whatever +// block it sits in. +#show link: it => underline(stroke: 0.6pt + ${hex(INK_FAINT)}, offset: 0.16em, it) +#show strike: set text(fill: ${hex(INK_SOFT)}) + +#show table: set text(size: 0.88em) +#show raw.where(block: true): it => block( + width: 100%, + fill: ${hex(CODE_SURFACE)}, + stroke: 0.5pt + ${hex(CODE_LINE)}, + radius: 5pt, + inset: (x: 0.9em, y: 0.75em), + above: 1.5em, + below: 1.5em, + breakable: true, + text(size: 0.82em, it), +) +#show raw.where(block: false): it => box( + fill: ${hex(CODE_WASH)}, + radius: 2.5pt, + inset: (x: 0.3em), + outset: (y: 0.2em), + text(size: 0.88em, it), +) + +#let doc-rule = block( + width: 100%, + above: 2.6em, + below: 2.6em, + line(length: 100%, stroke: 0.7pt + ${hex(DOC_RULE_STRONG)}), +) + +// Not italic, unlike a book's. A quote in a technical document is as likely to be a paragraph of a +// spec or somebody's bug report as an epigraph, and the bar and the ink carry it instead. +#let doc-quote(body) = block( + width: 100%, + inset: (left: 1.55em), + stroke: (left: 2pt + ${hex(DOC_RULE_STRONG)}), + above: 1.5em, + below: 1.5em, + text(fill: ${hex(INK_SOFT)}, body), +) + +#let doc-callout(edge, fill, label-ink, label, body) = block( + width: 100%, + fill: fill, + radius: 8pt, + stroke: (rest: 0.5pt + ${hex(DOC_RULE)}, left: 3pt + edge), + inset: (x: 1.15em, y: 0.95em), + above: 1.5em, + below: 1.5em, + { + text(font: ${str(UI_FONT)}, size: 0.68em, weight: 700, tracking: 0.12em, fill: label-ink, upper(label)) + v(0.5em, weak: true) + body + }, +) + +// Always open. A PDF has no disclosure to click, so a closed toggle would be a paragraph the export +// lost, and the summary is a line of its own above the body exactly as the is on screen. +#let doc-toggle(summary, body) = block( + width: 100%, + stroke: 0.5pt + ${hex(DOC_RULE)}, + radius: 8pt, + inset: (x: 1.1em, y: 0.85em), + above: 1.5em, + below: 1.5em, + { + text(weight: 600, summary) + v(0.5em, weak: true) + body + }, +) + +// Drawn rather than written, because a checkbox glyph is only a checkbox in a font that has one, +// and the faces behind this compile are not this module's to choose. +#let doc-check(done) = box( + width: 0.8em, + height: 0.8em, + radius: 2.5pt, + baseline: 0.08em, + stroke: 0.8pt + ${hex(INK_FAINT)}, + fill: if done { ${hex(INK_SOFT)} } else { none }, +) + +#let doc-figure(body) = block(width: 100%, above: 1.8em, below: 1.8em, align(center, body)) +`; +} + +// ------------------------------------------------------------------------------------------------ +// Images +// ------------------------------------------------------------------------------------------------ + +interface Context { + /** The document's own path, which every relative link and image src is resolved against. */ + readonly file: string; + readonly diagrams: Diagrams; + readonly mathMode: MathMode; + readonly images: ImageInput[]; + /** A src as the document wrote it, mapped to how the source refers to it, or null for a refusal. */ + readonly seen: Map; + readonly warnings: string[]; + math: boolean; + drawings: number; +} + +function extensionOf(dataUrl: string): string { + const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); + const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0]; + return kind === "jpeg" ? "jpg" : kind; +} + +/** + * How the source should refer to this image, and the entry the backend needs to find it. Null when + * there is nothing to point at: a picture on the web, which this app does not fetch, or an src that + * is not a path at all. + * + * A file-backed image travels as a path and the backend opens it, which is also where it is checked + * against the open roots, because an src in a document is untrusted input. Bytes only travel inline + * when there is no file behind them. + */ +function imagePath(ctx: Context, src: string): string | null { + const known = ctx.seen.get(src); + if (known !== undefined) return known; + + let path: string | null = null; + if (src.startsWith("data:image/")) { + const comma = src.indexOf(","); + const data = comma === -1 ? "" : src.slice(comma + 1); + if (data) { + path = `${INLINE}image-${ctx.images.length + 1}.${extensionOf(src)}`; + ctx.images.push({ path, data }); + } + } else { + // Sanitised once, here, so the path the source names and the path the backend is handed are the + // same string. They are compared for equality on the other side, and a path that went through + // this function twice has to come back the same both times. + const resolved = resolveRelative(ctx.file, src); + if (resolved !== null) { + path = sanitize(resolved); + ctx.images.push({ path, data: null }); + } + } + + if (path === null) ctx.warnings.push(`${src || "an image with no source"} could not be included`); + ctx.seen.set(src, path); + return path; +} + +function base64(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** A drawn diagram, which has no file behind it and so travels as bytes. */ +function diagramPath(ctx: Context, svg: string): string { + ctx.drawings += 1; + const path = `${INLINE}diagram-${ctx.drawings}.svg`; + ctx.images.push({ path, data: base64(svg) }); + return path; +} + +// ------------------------------------------------------------------------------------------------ +// Inline content +// ------------------------------------------------------------------------------------------------ + +function marked(node: ProseMirrorNode, body: string): string { + let out = body; + // Code is innermost and the emphasis goes around it, which is the direction src/markdown's + // serializer already picked for the same flat mark set. + if (node.marks.some((m) => m.type.name === "em")) out = `#emph[${out}]`; + if (node.marks.some((m) => m.type.name === "strong")) out = `#strong[${out}]`; + if (node.marks.some((m) => m.type.name === "strikethrough")) out = `#strike[${out}]`; + const href = node.marks.find((m) => m.type.name === "link")?.attrs.href; + if (typeof href === "string" && href) out = `#link(${str(href)})[${out}]`; + return out; +} + +function inline(ctx: Context, node: ProseMirrorNode): string { + switch (node.type.name) { + case "text": { + const text = node.text ?? ""; + if (!text) return ""; + const code = node.marks.some((m) => m.type.name === "code"); + // A code span keeps its whitespace: it is the one inline place where a run of spaces is + // content. Everything else flows. + return marked(node, code ? `#raw(${str(sanitize(text))})` : `#${str(flow(text))}`); + } + case "hardBreak": + return "#linebreak()"; + case "image": { + const path = imagePath(ctx, String(node.attrs.src ?? "")); + if (path === null) return altText(node); + // Sized to the line rather than left at its natural size, because an image sitting inside a + // sentence is a badge or an icon and a photograph does not go there. + return `#box(image(${str(path)}, height: 1em), baseline: 0.15em)`; + } + case "mathInline": { + const latex = String(node.attrs.latex ?? ""); + if (ctx.mathMode === "literal") return `#raw(${str(sanitize(latex))})`; + ctx.math = true; + return `#mi(${str(latex)})`; + } + default: + // Nothing else is inline in this schema. An unknown node still gets its text on the page, + // because a construct nobody thought of is worth less than a construct silently dropped. + return node.textContent ? `#${str(flow(node.textContent))}` : ""; + } +} + +/** What is left of an image that has no file: the words the author wrote in its place. */ +function altText(node: ProseMirrorNode): string { + const alt = String(node.attrs.alt ?? "").trim(); + return alt ? `#emph[#${str(flow(alt))}]` : ""; +} + +function inlines(ctx: Context, node: ProseMirrorNode): string { + let out = ""; + node.forEach((child) => { + out += inline(ctx, child); + }); + return out; +} + +/** Inline content as a Typst content block, which is what every layout helper takes. */ +function content(ctx: Context, node: ProseMirrorNode): string { + return `[${inlines(ctx, node)}]`; +} + +// ------------------------------------------------------------------------------------------------ +// Blocks +// ------------------------------------------------------------------------------------------------ + +/** The children of a container, as one content block. */ +function body(ctx: Context, node: ProseMirrorNode): string { + return `[${blocks(ctx, node)}]`; +} + +function blocks(ctx: Context, node: ProseMirrorNode): string { + const out: string[] = []; + node.forEach((child) => { + const rendered = block(ctx, child); + if (rendered) out.push(rendered); + }); + return out.join("\n\n"); +} + +function isImageOnly(node: ProseMirrorNode): boolean { + let images = 0; + let others = 0; + node.forEach((child) => { + if (child.type.name === "image") images += 1; + else if (child.type.name !== "text" || (child.text ?? "").trim()) others += 1; + }); + return images === 1 && others === 0; +} + +function paragraph(ctx: Context, node: ProseMirrorNode): string { + // Markdown has no block image: a picture on its own line is a paragraph holding nothing else, and + // that is the one this exporter gives a figure to rather than a line's worth of height. + if (isImageOnly(node)) { + let picture = ""; + node.forEach((child) => { + if (child.type.name === "image") picture = blockImage(ctx, child); + }); + if (picture) return picture; + } + const rendered = inlines(ctx, node); + // An empty paragraph is a blank line in a plain text file, and a blank line is content there. + return rendered || "#v(0.9em, weak: true)"; +} + +function blockImage(ctx: Context, node: ProseMirrorNode): string { + const path = imagePath(ctx, String(node.attrs.src ?? "")); + if (path === null) { + const alt = altText(node); + return alt || "#v(0.9em, weak: true)"; + } + const title = String(node.attrs.title ?? "").trim(); + const picture = `image(${str(path)})`; + if (!title) return `#doc-figure(${picture})`; + return `#doc-figure(figure(${picture}, caption: [#${str(flow(title))}]))`; +} + +function listItems(ctx: Context, node: ProseMirrorNode): string[] { + const items: string[] = []; + node.forEach((child) => { + // A GFM list may mix "- [ ] a" with ordinary items, so a checkbox can turn up inside a plain + // list. It rides at the head of the item rather than being dropped. + const box = child.type.name === "taskItem" ? `#doc-check(${child.attrs.checked ? "true" : "false"})#h(0.45em)` : ""; + items.push(`[${box}${blocks(ctx, child)}]`); + }); + return items; +} + +function taskList(ctx: Context, node: ProseMirrorNode): string { + const cells: string[] = []; + node.forEach((child) => { + // No leading `#`: a grid's arguments are already code, and the marker there is a call rather + // than an escape back into markup. + cells.push(`doc-check(${child.attrs.checked ? "true" : "false"})`); + cells.push(body(ctx, child)); + }); + if (cells.length === 0) return ""; + const gutter = node.attrs.tight ? "0.4em" : "0.85em"; + return `#grid(columns: (1.35em, 1fr), row-gutter: ${gutter}, align: (left + top, left + top), ${cells.join(", ")})`; +} + +const ALIGNMENTS: Record = { left: "left", center: "center", right: "right" }; + +function table(ctx: Context, node: ProseMirrorNode): string { + const rows: ProseMirrorNode[] = []; + node.forEach((row) => rows.push(row)); + if (rows.length === 0) return ""; + + const widthOf = (row: ProseMirrorNode): number => { + let width = 0; + row.forEach((cell) => { + width += Math.max(1, Number(cell.attrs.colspan) || 1); + }); + return width; + }; + const columns = Math.max(1, ...rows.map(widthOf)); + + // GFM carries one alignment per column on the delimiter row and the bridge copies it on to every + // cell in that column, so the first row is as good a place to read it as any. + const aligns: string[] = []; + rows[0].forEach((cell) => { + const align = ALIGNMENTS[String(cell.attrs.align ?? "")] ?? "left"; + for (let i = 0; i < Math.max(1, Number(cell.attrs.colspan) || 1); i += 1) aligns.push(align); + }); + while (aligns.length < columns) aligns.push("left"); + aligns.length = columns; + + const cellsOf = (row: ProseMirrorNode): string[] => { + const out: string[] = []; + row.forEach((cell) => { + const inner = cell.type.name === "tableHeader" ? `[#strong[${inlines(ctx, cell)}]]` : content(ctx, cell); + const colspan = Math.max(1, Number(cell.attrs.colspan) || 1); + const rowspan = Math.max(1, Number(cell.attrs.rowspan) || 1); + out.push( + colspan === 1 && rowspan === 1 + ? inner + : `table.cell(colspan: ${colspan}, rowspan: ${rowspan})${inner}`, + ); + }); + return out; + }; + + let heads = 0; + rows[0].forEach((cell) => { + if (cell.type.name === "tableHeader") heads += 1; + }); + const headed = heads > 0 && heads === rows[0].childCount; + + const parts: string[] = []; + rows.forEach((row, index) => { + const cells = cellsOf(row); + if (cells.length === 0) return; + if (index === 0 && headed) parts.push(`table.header(${cells.join(", ")})`); + else parts.push(cells.join(", ")); + }); + if (parts.length === 0) return ""; + + // Horizontal rules and a washed header row: prose.css's table, which has no vertical rules at all + // because a document table is not a spreadsheet. + const fill = headed ? `\n fill: (_, y) => if y == 0 { ${hex(DOC_WASH)} },` : ""; + return `#table( + columns: ${columns}, + align: (${aligns.join(", ")}), + stroke: (x: none, y: 0.5pt + ${hex(DOC_RULE)}), + inset: (x: 0.8em, y: 0.5em),${fill} + ${parts.join(",\n ")}, +)`; +} + +function callout(ctx: Context, node: ProseMirrorNode): string { + const declared = String(node.attrs.kind ?? "note"); + const kind = calloutKindFromLabel(declared); + const colours = CALLOUT_COLOURS[kind ?? "note"]; + // A kind nobody has heard of keeps its own word as the label. It is text off disk either way, so + // it goes through `str` either way; only the colours are chosen here, and never by the document. + const label = kind === null ? declared.trim() || "note" : CALLOUT_LABELS[kind]; + return `#doc-callout(${hex(colours.edge)}, ${hex(colours.fill)}, ${hex(colours.label)}, ${str(flow(label))}, ${body(ctx, node)})`; +} + +function codeBlock(ctx: Context, node: ProseMirrorNode): string { + const text = sanitize(node.textContent); + const language = typeof node.attrs.language === "string" ? node.attrs.language.trim() : ""; + + if (language === MERMAID) { + const svg = ctx.diagrams.get(node.textContent); + // A diagram that would not draw stays the fence the user wrote, which is the same answer the + // editor gives on screen: a broken diagram is a line to go and fix, not a block to hide. + if (svg) return `#doc-figure(image(${str(diagramPath(ctx, svg))}))`; + } + + const lang = language ? `, lang: ${str(language)}` : ""; + return `#raw(${str(text)}${lang}, block: true)`; +} + +function block(ctx: Context, node: ProseMirrorNode): string { + switch (node.type.name) { + case "paragraph": + return paragraph(ctx, node); + case "heading": { + const level = Math.min(6, Math.max(1, Number(node.attrs.level) || 1)); + return `#heading(level: ${level})${content(ctx, node)}`; + } + case "blockquote": + return `#doc-quote(${body(ctx, node)})`; + case "bulletList": { + const items = listItems(ctx, node); + return items.length ? `#list(tight: ${node.attrs.tight ? "true" : "false"}, ${items.join(", ")})` : ""; + } + case "orderedList": { + const items = listItems(ctx, node); + const start = Number(node.attrs.start); + const from = Number.isFinite(start) ? Math.max(0, Math.trunc(start)) : 1; + return items.length + ? `#enum(start: ${from}, tight: ${node.attrs.tight ? "true" : "false"}, ${items.join(", ")})` + : ""; + } + case "taskList": + return taskList(ctx, node); + case "codeBlock": + return codeBlock(ctx, node); + case "horizontalRule": + return "#doc-rule"; + case "table": + return table(ctx, node); + case "callout": + return callout(ctx, node); + case "toggle": + // Open, always. A PDF has no disclosure to click, so a closed toggle would be a paragraph the + // export lost. + return `#doc-toggle(${str(flow(String(node.attrs.summary ?? "")))}, ${body(ctx, node)})`; + case "mathBlock": { + // Bare, with none of the surface the editor draws around it. On screen a formula is a box of + // monospace source because that is what is being edited; on the page it is typeset, and + // typeset mathematics does not want a code block's border around it. + const latex = String(node.attrs.latex ?? ""); + if (ctx.mathMode === "literal") return `#raw(${str(sanitize(latex))}, block: true)`; + ctx.math = true; + return `#mitex(${str(latex)})`; + } + case "raw": + // The bytes the bridge could not model, shown as what they are. No language: this is source + // in no particular language, and guessing one would colour it wrong. + return `#raw(${str(sanitize(node.textContent))}, block: true)`; + default: { + // A node this file has never heard of still reaches the page. Its own children are walked + // where it has any, so a future container does not take its contents down with it. + if (node.isTextblock) return inlines(ctx, node) || ""; + const inner = blocks(ctx, node); + return inner || (node.textContent ? `#${str(flow(node.textContent))}` : ""); + } + } +} + +// ------------------------------------------------------------------------------------------------ +// Mermaid +// ------------------------------------------------------------------------------------------------ + +/** Every mermaid fence in the document, in order, deduplicated. Pure, so a test can call it. */ +export function diagramSources(doc: ProseMirrorNode): string[] { + const out: string[] = []; + doc.descendants((node) => { + if (node.type.name !== "codeBlock") return true; + if (node.attrs.language === MERMAID && node.textContent.trim() && !out.includes(node.textContent)) { + out.push(node.textContent); + } + return false; + }); + return out; +} + +let configured = false; +let drawn = 0; + +/** + * Mermaid is several megabytes, so this import is dynamic exactly as the editor's own is: a user who + * never exports a document with a diagram in it never fetches the library. + * + * The theme is `neutral` rather than the app's own palette, and that is the one place this differs + * from the block on screen. A drawn diagram bakes its colours in, the page it is going on to is + * white whatever the app's theme is, and mermaid's neutral theme is the one meant for a document + * that will be printed. Exporting in dark mode must not produce a black rectangle on white paper. + */ +async function draw(text: string): Promise { + const mermaid = (await import("mermaid")).default; + if (!configured) { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + theme: "neutral", + }); + configured = true; + } + await mermaid.parse(text); + drawn += 1; + const { svg } = await mermaid.render(`typst-diagram-${drawn}`, text); + return svg; +} + +/** + * Every diagram in the document, drawn. Needs a DOM, which is why it is not part of the conversion: + * a fence whose render failed is simply absent from the map and comes out of the converter as the + * code it always was. + */ +export async function renderDiagrams(doc: ProseMirrorNode): Promise> { + const svgs = new Map(); + for (const text of diagramSources(doc)) { + try { + svgs.set(text, await draw(text)); + } catch { + // Deliberately swallowed. The caller counts what is missing; one diagram that will not parse + // is not a reason to refuse the other forty pages. + } + } + return svgs; +} + +// ------------------------------------------------------------------------------------------------ +// The document +// ------------------------------------------------------------------------------------------------ + +const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1) || path; + +/** + * One document, as Typst source and the images it refers to. + * + * `path` is the file the document came from. It is what relative image sources resolve against, and + * it is the PDF's title: a markdown file's identity is its path and the H1 is content, so a + * document whose first heading disagrees with its filename is not renamed on the way out. There is + * no frontmatter here to strip, because the bridge never put it in the tree. + * + * `diagrams` comes from `renderDiagrams`, which is async and wants a DOM. Everything below is + * neither, so the whole conversion is a pure function of the document. + */ +export function documentToTypst(doc: ProseMirrorNode, path: string, options: TypstOptions = {}): TypstDocument { + const ctx: Context = { + file: path, + diagrams: options.diagrams ?? new Map(), + mathMode: options.math ?? "typeset", + images: [], + seen: new Map(), + warnings: [], + math: false, + drawings: 0, + }; + const rendered = blocks(ctx, doc); + return { + source: `${preamble(baseName(path), ctx.math)}\n${rendered}\n`, + images: ctx.images, + warnings: ctx.warnings, + }; +} diff --git a/src/ipc.ts b/src/ipc.ts index b7ed002..38b487a 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -48,6 +48,9 @@ export const WATCH_EVENT = "watch-event"; /** The `index-progress` event, whose payload is an `IndexStatus`. */ export const INDEX_PROGRESS_EVENT = "index-progress"; +/** The `pdf-warnings` event, whose payload is a `PdfWarning[]`. */ +export const PDF_WARNINGS_EVENT = "pdf-warnings"; + export type MenuAction = | "open-folder" | "new-doc" @@ -61,7 +64,10 @@ export type MenuAction = | "command-palette" | "toggle-sidebar" | "check-updates" - | "report-issue"; + | "report-issue" + | "export-pdf" + | "writing-proofread" + | "writing-rewrite"; /** * One open folder. `id` is derived from the path, so it survives a relaunch and a root can be @@ -220,6 +226,56 @@ export interface SpellIssue { suggestions: string[]; } +/** + * An image the PDF exporter has to put on a page. + * + * `data` present means the bytes travel with the request, base64 encoded, which is the only way a + * mermaid diagram can arrive: it is rendered to SVG here and there is no file behind it. `data` + * absent means the backend reads the file at `path` itself, which is what an ordinary + * `![](photo.jpg)` is and what most images are, because base64 encoding a folder of photographs + * through the IPC boundary costs a third again in bytes for files the backend can already open. + * + * A path read this way is checked against the open roots on the Rust side, because a link in a + * document is untrusted input. + */ +export interface ImageInput { + /** How the Typst source refers to it, which is also the key the file resolver answers on. */ + path: string; + data: string | null; +} + +/** + * Something the exporter worked around rather than something it refused to do. Payload of + * `pdf-warnings`, which is how these travel: a compile answers with raw bytes and has nowhere to + * put a second value. + * + * `count` is here because the alternative is forty toasts. A document with forty formulas that + * would not typeset has one problem, not forty. + */ +export interface PdfWarning { + kind: "math" | "image" | "typst"; + message: string; + count: number; +} + +/** + * One grammar problem in a run of text handed to the checker. + * + * `start` and `end` are half-open offsets in characters, for the same reason `SpellIssue` gives: + * they address a ProseMirror document and ProseMirror counts code points. Harper counts that way + * too, so this path has no conversion in it at all. + * + * `kind` is Harper's own name for the rule that fired, which the popover shows above the message + * so a correction can be judged before it is taken. `suggestions` can be empty. + */ +export interface GrammarIssue { + start: number; + end: number; + kind: string; + message: string; + suggestions: string[]; +} + /** * In Tauri this is `invoke`. Opened in a browser during development it is served from the dev * fixture instead, so the real UI can be driven and looked at without a build of the Rust side diff --git a/src/keys/bindings.ts b/src/keys/bindings.ts index 32e52fd..97d557c 100644 --- a/src/keys/bindings.ts +++ b/src/keys/bindings.ts @@ -49,6 +49,13 @@ export const BINDINGS: readonly Binding[] = [ { keys: ["cmd+o"], command: "open-folder", context: "document", group: "File", allowInInput: true }, { keys: ["cmd+n"], command: "new-doc", context: "document", group: "File", allowInInput: true }, { keys: ["cmd+s"], command: "save", context: "document", group: "File", allowInInput: true }, + { + keys: ["cmd+E"], + command: "export-pdf", + context: "document", + group: "File", + allowInInput: true, + }, { keys: ["cmd+p"], @@ -94,6 +101,29 @@ export const BINDINGS: readonly Binding[] = [ allowInInput: true, }, + // Writing Tools' two chords. Like every accelerator this app puts on a native menu item, macOS + // fires the menu row before the webview sees a keydown, so in practice these are dispatched + // through src/keys/menu.ts rather than by the listener in keymap.ts. They are rows here for the + // same reason Cmd+O is: the sheet is generated from this table, and a key the app answers to + // that is missing from it would be the one thing this table exists to prevent. + // + // They are on this app's own Edit rows rather than on Apple's, so that they pass the selection + // guard in src/editor/writing.ts. src-tauri/src/writingtools.rs says what that is protecting. + { + keys: ["alt+F"], + command: "writing-proofread", + context: "document", + group: "Editing", + allowInInput: true, + }, + { + keys: ["alt+R"], + command: "writing-rewrite", + context: "document", + group: "Editing", + allowInInput: true, + }, + { keys: ["cmd+\\"], command: "toggle-sidebar", diff --git a/src/keys/commands.ts b/src/keys/commands.ts index 947c767..83debdd 100644 --- a/src/keys/commands.ts +++ b/src/keys/commands.ts @@ -12,8 +12,9 @@ // correctly. import { openUrl } from "@tauri-apps/plugin-opener"; -import { relaunch } from "@tauri-apps/plugin-process"; -import { check } from "@tauri-apps/plugin-updater"; +import { runWritingTool } from "../editor/writing"; +import { exportPdf } from "../export/run"; +import { checkForUpdates } from "../update"; import { useDocument } from "../store/useDocument"; import { useProofing } from "../store/useProofing"; import { useTheme } from "../store/useTheme"; @@ -28,6 +29,7 @@ export type CommandId = | "new-folder" | "close-folder" | "save" + | "export-pdf" | "rename-file" | "duplicate-file" | "delete-file" @@ -44,7 +46,10 @@ export type CommandId = | "editor-width-normal" | "editor-width-wide" | "toggle-spelling" + | "toggle-grammar" | "correct-spelling" + | "writing-proofread" + | "writing-rewrite" | "shortcuts" | "settings" | "check-updates" @@ -217,27 +222,6 @@ async function goForward(): Promise { } } -let checkingForUpdates = false; - -async function checkForUpdates(): Promise { - if (checkingForUpdates) return; - checkingForUpdates = true; - try { - const update = await check(); - if (!update) { - notify("Margin Docs is up to date"); - return; - } - notify(`Installing ${update.version}…`); - await update.downloadAndInstall(); - await relaunch(); - } catch (e) { - notify(`Could not check for updates: ${String(e)}`); - } finally { - checkingForUpdates = false; - } -} - const listeners = new Map void>>(); /** @@ -264,6 +248,7 @@ const TABLE: Record> = { "new-folder": { label: "New Folder", palette: true, run: () => void createFolder() }, "close-folder": { label: "Close Folder", palette: false, run: closeActiveFolder }, save: { label: "Save", palette: true, run: () => void saveDocument() }, + "export-pdf": { label: "Export as PDF…", palette: true, run: () => void exportPdf() }, "rename-file": { label: "Rename", palette: false, run: () => void renameSelected() }, "duplicate-file": { label: "Duplicate", palette: false, run: () => void duplicateSelected() }, "delete-file": { label: "Delete", palette: false, run: () => void deleteSelected() }, @@ -320,6 +305,15 @@ const TABLE: Record> = { run: () => useProofing.getState().toggle(), }, + // Its twin, and turned over the same way. The two are separate settings because the checkers + // are: spelling is the system's and grammar is Harper's, and a user who wants one without the + // other is not asking for anything strange. + "toggle-grammar": { + label: "Check Grammar While Typing", + palette: true, + run: () => useProofing.getState().toggleGrammar(), + }, + // Dispatched, unlike the one above it, because its whole result is the correction menu on screen // and the component that draws that menu is the only thing here that knows where the caret is. // @@ -332,6 +326,22 @@ const TABLE: Record> = { run: () => dispatch("correct-spelling"), }, + // Writing Tools is the system's, but these two commands are not, and that is the point: the + // chords and the menu rows are this app's own, so every way in passes the selection guard in + // src/editor/writing.ts before the system is asked to rewrite anything. On a Mac without Apple + // Intelligence there is no submenu to perform, and the honest answer is a sentence rather than a + // gesture that quietly does nothing. + "writing-proofread": { + label: "Proofread with Writing Tools", + palette: true, + run: () => void runWritingTool("proofread"), + }, + "writing-rewrite": { + label: "Rewrite with Writing Tools", + palette: true, + run: () => void runWritingTool("rewrite"), + }, + shortcuts: { label: "Keyboard Shortcuts", palette: true, run: () => dispatch("shortcuts") }, settings: { label: "Settings…", palette: true, run: () => dispatch("settings") }, "check-updates": { diff --git a/src/keys/menu.ts b/src/keys/menu.ts index 13212d1..ac28e51 100644 --- a/src/keys/menu.ts +++ b/src/keys/menu.ts @@ -12,6 +12,7 @@ export const MENU_IDS: readonly CommandId[] = [ "new-doc", "new-folder", "save", + "export-pdf", "close-folder", "settings", "find", @@ -21,6 +22,8 @@ export const MENU_IDS: readonly CommandId[] = [ "toggle-sidebar", "check-updates", "report-issue", + "writing-proofread", + "writing-rewrite", ]; const known = new Set(MENU_IDS); diff --git a/src/main.tsx b/src/main.tsx index c6f0827..1c5722e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -29,6 +29,7 @@ import "./styles/width-menu.css"; import "./styles/link-picker.css"; import "./styles/tree.css"; import "./styles/palette.css"; +import "./styles/settings.css"; import React from "react"; import ReactDOM from "react-dom/client"; diff --git a/src/store/useProofing.ts b/src/store/useProofing.ts index 074f864..a5e8ba1 100644 --- a/src/store/useProofing.ts +++ b/src/store/useProofing.ts @@ -1,6 +1,10 @@ -// Spelling, as far as the app outside the editor is concerned: whether the machine has a checker at +// Proofing, as far as the app outside the editor is concerned: whether the machine has a checker at // all, whether the user wants underlines, which words they have waved away this session, and which -// misspelling the menu is currently open over. +// underline the menu is currently open over. +// +// Two checkers, one store. Spelling is the system's and grammar is Harper's, they are asked +// separately and can be turned on separately, but they share a decoration pipeline, a popover and +// this state, because from the reader's side they are one underline under one word. // // The checker itself is the system's (see src/api/spell.ts), which is why availability is a piece of // state rather than a constant. `spellAvailable` is asked once per launch and never again: it @@ -19,10 +23,12 @@ // the cache: it filters what is drawn, so waving a word away and putting it back costs nothing. import { create } from "zustand"; +import { grammarAvailable } from "../api/grammar"; import { spellAvailable, spellLearn } from "../api/spell"; import { notify } from "./useToast"; const KEY = "margindocs-spelling"; +const GRAMMAR_KEY = "margindocs-grammar"; /** * Where the menu is, and what it is about. @@ -34,11 +40,18 @@ const KEY = "margindocs-spelling"; export interface ProofTarget { from: number; to: number; - /** What the checker flagged, which is also what "Learn Spelling" and "Ignore" are about. */ + /** What the checker flagged, which is also what "Learn Spelling" and "Ignore" are about. A word + * for spelling and however much of the sentence Harper was talking about for grammar. */ word: string; /** At most five, already trimmed by the editor. Can be empty: the system often knows a word is - * wrong without knowing what was meant. */ + * wrong without knowing what was meant, and a grammar rule can see a sentence is wrong without + * knowing how to fix it. */ suggestions: readonly string[]; + /** Harper's category for the rule that fired and the sentence it wrote about it, and null when + * this underline is a misspelling. It is what the popover shows above the suggestions, and it is + * also what decides which items are offered: "Learn Spelling" teaches the Mac's dictionary a word + * and has nothing to say about a phrase. */ + grammar: { kind: string; message: string } | null; /** Viewport coordinates of the word itself, for placing the menu under it. */ left: number; top: number; @@ -53,10 +66,18 @@ export type SpellAvailability = "unknown" | "asking" | "ready" | "missing"; interface ProofingState { availability: SpellAvailability; + /** The same question about the other checker, asked once per launch alongside it. Grammar is + * Harper's and compiled in, so "missing" here means a build without it rather than a machine + * without it, and it hides the setting rather than offering one that does nothing. */ + grammarAvailability: SpellAvailability; enabled: boolean; + /** Grammar, which is a separate setting because it is a separate checker. A user who wants + * spelling underlined and grammar left alone is not asking for anything strange. */ + grammar: boolean; /** Lower cased, and only for this run of the app. Ignoring is not learning and is not written * anywhere: the system checker owns the dictionary, and a word waved away in one sitting is not a - * word the user has taught their Mac. */ + * word the user has taught their Mac. Grammar shares the set and puts whole phrases in it, which + * cannot collide with a word: nothing the spell checker flags has a space in it. */ ignored: ReadonlySet; revision: number; target: ProofTarget | null; @@ -64,6 +85,8 @@ interface ProofingState { ensureAvailable: () => void; setEnabled: (enabled: boolean) => void; toggle: () => void; + setGrammar: (enabled: boolean) => void; + toggleGrammar: () => void; ignoreWord: (word: string) => void; learnWord: (word: string) => Promise; openMenu: (target: ProofTarget) => void; @@ -75,19 +98,19 @@ interface ProofingState { // because applying one writes an attribute on to the document element. This is one boolean that // nothing outside this store touches, and guarded the same way they are, for the Node test // environment that reaches this file through src/keys/commands.ts. -function readEnabled(): boolean { +function readSetting(key: string): boolean { if (typeof localStorage === "undefined") return true; try { - return localStorage.getItem(KEY) !== "off"; + return localStorage.getItem(key) !== "off"; } catch { return true; } } -function writeEnabled(enabled: boolean): void { +function writeSetting(key: string, enabled: boolean): void { if (typeof localStorage === "undefined") return; try { - localStorage.setItem(KEY, enabled ? "on" : "off"); + localStorage.setItem(key, enabled ? "on" : "off"); } catch { // A webview with storage denied still spell checks, it just forgets between launches. } @@ -95,28 +118,49 @@ function writeEnabled(enabled: boolean): void { export const useProofing = create((set, get) => ({ availability: "unknown", - enabled: readEnabled(), + grammarAvailability: "unknown", + enabled: readSetting(KEY), + grammar: readSetting(GRAMMAR_KEY), ignored: new Set(), revision: 0, target: null, + // Both checkers, each guarded on its own answer rather than on the pair. They are asked together + // today, so one guard would do; a guard that reads the wrong field is how the second checker ends + // up stuck on "unknown" the first time somebody asks them apart. ensureAvailable: () => { - if (get().availability !== "unknown") return; - set({ availability: "asking" }); - spellAvailable() - .then((ok) => set({ availability: ok ? "ready" : "missing" })) - .catch(() => set({ availability: "missing" })); + if (get().availability === "unknown") { + set({ availability: "asking" }); + spellAvailable() + .then((ok) => set({ availability: ok ? "ready" : "missing" })) + .catch(() => set({ availability: "missing" })); + } + if (get().grammarAvailability === "unknown") { + set({ grammarAvailability: "asking" }); + grammarAvailable() + .then((ok) => set({ grammarAvailability: ok ? "ready" : "missing" })) + .catch(() => set({ grammarAvailability: "missing" })); + } }, setEnabled: (enabled) => set((s) => { if (s.enabled === enabled) return {}; - writeEnabled(enabled); + writeSetting(KEY, enabled); return { enabled, target: null }; }), toggle: () => get().setEnabled(!get().enabled), + setGrammar: (enabled) => + set((s) => { + if (s.grammar === enabled) return {}; + writeSetting(GRAMMAR_KEY, enabled); + return { grammar: enabled, target: null }; + }), + + toggleGrammar: () => get().setGrammar(!get().grammar), + ignoreWord: (word) => set((s) => { const ignored = new Set(s.ignored); diff --git a/src/store/useUpdate.ts b/src/store/useUpdate.ts new file mode 100644 index 0000000..5906b40 --- /dev/null +++ b/src/store/useUpdate.ts @@ -0,0 +1,129 @@ +// What the updater is doing, plus the two preferences that sit next to it in the settings panel. +// +// The work itself is in src/update.ts, which is this store's sibling: checking, downloading and +// relaunching all cross the IPC boundary, and the handle `check()` hands back is a resource on the +// Rust side rather than a value, so it cannot be kept here beside the version string it arrived +// with. This file is only what the dialog and the settings panel read. +// +// `lastChecked` and `automatic` are read and written here rather than in a module of their own, the +// same way src/store/useProofing.ts keeps its two settings. Nothing outside this store touches +// either key, and the guard around localStorage is for the Node test environment, which reaches +// this file through src/keys/commands.ts. + +import { create } from "zustand"; + +const CHECKED_KEY = "margindocs-update-checked"; +const AUTOMATIC_KEY = "margindocs-update-automatic"; + +/** + * "available" is an update the user has been shown and has not answered yet, which is the dialog + * sitting open. "downloading" and "installing" are the two halves of what one press of Install does, + * and they are separate because only the first of them has a number to draw. + * + * There is no "done": the last thing installing does is relaunch, so the successful end of this + * union is the process going away. + */ +export type UpdatePhase = + | "idle" + | "checking" + | "available" + | "downloading" + | "installing" + | "error"; + +interface UpdateState { + phase: UpdatePhase; + /** The version on offer, not the one running. Null outside "available" and what follows it. */ + version: string | null; + /** The release notes, as the release wrote them. Plain text, and shown as plain text. */ + notes: string | null; + downloaded: number; + /** + * What the server said the whole download is, or null when it did not say. A server that sends no + * content length leaves this null for the entire download, which the dialog draws as a bar with + * no end rather than as nought percent forever. + */ + total: number | null; + error: string | null; + /** Epoch milliseconds of the last check that actually reached the manifest. */ + lastChecked: number | null; + automatic: boolean; + + begin: () => void; + offer: (version: string, notes: string | null) => void; + progress: (downloaded: number, total: number | null) => void; + installing: () => void; + failed: (message: string) => void; + /** Back to nothing on screen, whether that is Later, Escape or a check that found nothing. */ + dismiss: () => void; + markChecked: () => void; + setAutomatic: (automatic: boolean) => void; +} + +function readAutomatic(): boolean { + if (typeof localStorage === "undefined") return true; + try { + return localStorage.getItem(AUTOMATIC_KEY) !== "off"; + } catch { + return true; + } +} + +function readChecked(): number | null { + if (typeof localStorage === "undefined") return null; + try { + const raw = localStorage.getItem(CHECKED_KEY); + if (raw === null) return null; + const at = Number(raw); + return Number.isFinite(at) ? at : null; + } catch { + return null; + } +} + +function write(key: string, value: string): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(key, value); + } catch { + // A webview with storage denied still checks for updates, it just forgets when it last did. + } +} + +export const useUpdate = create((set) => ({ + phase: "idle", + version: null, + notes: null, + downloaded: 0, + total: null, + error: null, + lastChecked: readChecked(), + automatic: readAutomatic(), + + begin: () => set({ phase: "checking", error: null }), + + offer: (version, notes) => + set({ phase: "available", version, notes, downloaded: 0, total: null, error: null }), + + progress: (downloaded, total) => set({ phase: "downloading", downloaded, total }), + + installing: () => set({ phase: "installing" }), + + failed: (message) => set({ phase: "error", error: message }), + + dismiss: () => + set({ phase: "idle", version: null, notes: null, downloaded: 0, total: null, error: null }), + + markChecked: () => { + const at = Date.now(); + write(CHECKED_KEY, String(at)); + return set({ lastChecked: at }); + }, + + setAutomatic: (automatic) => + set((s) => { + if (s.automatic === automatic) return {}; + write(AUTOMATIC_KEY, automatic ? "on" : "off"); + return { automatic }; + }), +})); diff --git a/src/styles/proofing.css b/src/styles/proofing.css index 7e5abbe..392ea94 100644 --- a/src/styles/proofing.css +++ b/src/styles/proofing.css @@ -1,4 +1,4 @@ -/* Spelling: the underline on the page, and the menu that opens over it. +/* Spelling and grammar: the underlines on the page, and the menu that opens over one. * * The underline is a text decoration rather than a border or a background, because it is the one * way of drawing under a word that costs the line nothing: a decoration is painted inside the line @@ -8,20 +8,40 @@ * red rather than a hex value of this file's own. skip-ink is off so a descender does not cut a gap * in the dots and leave a word looking half underlined. * + * Grammar is the second mark and it is told apart three ways: dashed rather than dotted, quieter, + * and sitting a little lower. The pattern is doing as much of that work as the colour, so the two + * are still two marks to a reader who cannot tell the colours apart, and the lower offset is what + * keeps both visible when they land on the same word, which they do whenever a misspelling sits + * inside a phrase Harper flagged. The colour is --ink-soft rather than a hue of its own: this app's + * palette is a warm monochrome with one red in it, spending that red on the commoner and more + * certain of the two marks is the right way round, and a blue borrowed from another app's grammar + * checker would be the only blue anywhere in the product. + * * The menu borrows .row-menu-pop's shape from app.css, which is the app's one popup surface: same * paper, same hairline, same shadow, same radius, same layer. The suggestions are set in the * document's own face and not the UI one, since a suggestion is a word from the user's prose and - * seeing it in the type it will be set in is half of choosing it. */ + * seeing it in the type it will be set in is half of choosing it. Everything above them is the UI + * face, because Harper's message is the app talking rather than the document. */ -.prose .proof-mark { - text-decoration: underline dotted var(--danger); - -webkit-text-decoration: underline dotted var(--danger); +.prose .proof-mark, +.prose .proof-mark-grammar { text-decoration-skip-ink: none; text-decoration-thickness: 1.5px; -webkit-text-decoration-thickness: 1.5px; text-underline-offset: 0.16em; } +.prose .proof-mark { + text-decoration: underline dotted var(--danger); + -webkit-text-decoration: underline dotted var(--danger); +} + +.prose .proof-mark-grammar { + text-decoration: underline dashed var(--ink-soft); + -webkit-text-decoration: underline dashed var(--ink-soft); + text-underline-offset: 0.28em; +} + .proof-pop { position: fixed; z-index: 50; @@ -75,6 +95,30 @@ outline-offset: -2px; } +/* Harper's category, drawn as app.css draws .menu-label, which is this app's one way of putting a + heading above a group of items in a popup. */ +.proof-kind { + margin: 0; + padding: 6px 10px 2px; + font-family: var(--font-ui); + font-size: var(--t-1); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-faint); +} + +/* The message itself, in full ink and wrapping, because it is the thing the menu was opened to + read. The max-width on .proof-pop above is what keeps it to a few short lines. */ +.proof-message { + margin: 0; + padding: 0 10px 6px; + font-family: var(--font-ui); + font-size: var(--t-3); + line-height: 1.45; + color: var(--ink); +} + .proof-none { margin: 0; padding: 7px 10px; diff --git a/src/styles/settings.css b/src/styles/settings.css new file mode 100644 index 0000000..d4a11f4 --- /dev/null +++ b/src/styles/settings.css @@ -0,0 +1,211 @@ +/* The settings panel and the update dialog. + * + * Both borrow .overlay, .panel, .panel-head, .panel-body and .panel-foot from app.css, and the + * three buttons from tree.css, for the same reason the width menu borrows the menu: a dialog that + * looked like its own app would be the only thing on screen that did. What is here is the two + * layouts those shells hold, the switch, and the progress bar. + * + * The switch is the one control this app has that nothing else needed, so it is drawn here rather + * than in app.css. It is a button with role="switch" and its state is data-on, which is what the + * whole app does with a control that is either on or off. */ + +.panel-settings { + width: min(520px, calc(100vw - 32px)); +} + +.panel-settings .panel-body { + gap: 24px; +} + +.setting-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* The label is spaced for a sidebar over in app.css, and the panel has its own padding already. */ +.panel-settings .nav-label { + padding: 0 0 6px; +} + +.setting-app { + font-family: var(--font-book); + font-size: var(--t-5); + font-weight: 500; + color: var(--ink); +} + +.setting-build { + font-family: var(--font-ui); + font-size: var(--t-3); + color: var(--ink-faint); +} + +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 34px; + padding: 5px 0; +} + +.setting-row + .setting-row { + border-top: 1px solid var(--line); +} + +.setting-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.setting-label { + font-family: var(--font-ui); + font-size: var(--t-4); + color: var(--ink); +} + +.setting-note { + font-family: var(--font-ui); + font-size: var(--t-2); + color: var(--ink-faint); +} + +.setting-row[data-disabled="true"] .setting-label { + color: var(--ink-faint); +} + +/* A button that carries an action rather than an answer: Check Now sits where a switch sits, so it + is sized to match one rather than to match the buttons along the foot of a dialog. */ +.btn-quiet { + flex: none; + padding: 6px 14px; + border: 1px solid var(--line-strong); + border-radius: var(--r-sm); + background: var(--raised); + font-family: var(--font-ui); + font-size: var(--t-3); + font-weight: 500; + color: var(--ink-soft); + transition: background 120ms var(--ease), color 120ms var(--ease), + border-color 120ms var(--ease); +} + +.btn-quiet:hover:not(:disabled) { + border-color: var(--accent); + color: var(--ink); +} + +.btn-quiet:disabled { + color: var(--ink-faint); + cursor: default; +} + +.switch { + flex: none; + position: relative; + width: 38px; + height: 22px; + padding: 0; + border: 1px solid var(--line-strong); + border-radius: var(--r-pill); + background: var(--shell); + transition: background 140ms var(--ease), border-color 140ms var(--ease); +} + +.switch[data-on="true"] { + background: var(--accent); + border-color: var(--accent); +} + +.switch:disabled { + opacity: 0.45; + cursor: default; +} + +.switch-knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--paper); + box-shadow: var(--shadow-raised); + transition: transform 140ms var(--ease), background 140ms var(--ease); +} + +.switch[data-on="true"] .switch-knob { + transform: translateX(16px); + background: var(--accent-contrast); +} + +/* The update dialog */ + +.panel-update { + width: min(460px, calc(100vw - 32px)); +} + +/* The release notes as the release wrote them, which is text and not markup. Preserved rather than + parsed: this is the one string in the app that arrives from outside it, and running it through a + renderer would be the only place where somebody else's bytes decide what is drawn. */ +.update-notes { + max-height: 180px; + overflow-y: auto; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--raised); + font-family: var(--font-ui); + font-size: var(--t-3); + line-height: 1.55; + color: var(--ink-soft); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.update-progress { + display: flex; + flex-direction: column; + gap: 8px; +} + +.update-track { + position: relative; + height: 5px; + border-radius: var(--r-pill); + background: var(--accent-wash); + overflow: hidden; +} + +.update-bar { + height: 100%; + border-radius: var(--r-pill); + background: var(--accent); + transition: width 180ms var(--ease); +} + +/* A server that sends no content length leaves nothing to measure, so the bar says "still going" + rather than claiming a fraction it does not know. */ +.update-track[data-unknown="true"] .update-bar { + width: 34%; + animation: update-sweep 1400ms var(--ease) infinite; +} + +@keyframes update-sweep { + 0% { + transform: translateX(-110%); + } + 100% { + transform: translateX(320%); + } +} + +.update-count { + font-family: var(--font-ui); + font-size: var(--t-2); + color: var(--ink-faint); + font-variant-numeric: tabular-nums; +} diff --git a/src/update.ts b/src/update.ts new file mode 100644 index 0000000..9b62647 --- /dev/null +++ b/src/update.ts @@ -0,0 +1,171 @@ +// The updater, from the frontend's side: asking whether there is one, downloading it with progress +// somebody can watch, and relaunching without losing an edit made half a second ago. +// +// `relaunch()` does not go through the window close handler that protects a dirty buffer, so +// anything here that relaunches flushes a pending save first. +// +// Everything Tauri-shaped lives here and the state it produces lives in src/store/useUpdate.ts, +// which is what the dialog and the settings panel read. The one thing that cannot go in that store +// is `offered` below: the value `check()` hands back is a resource handle, a number with a download +// behind it on the Rust side, so it is held here for as long as the dialog is open and closed when +// the dialog is not. + +import { getVersion } from "@tauri-apps/api/app"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { check, type Update } from "@tauri-apps/plugin-updater"; +import { flushPendingSave } from "./document"; +import { isDesktop, isTauri } from "./ipc"; +import { useUpdate } from "./store/useUpdate"; +import { notify } from "./store/useToast"; + +/** + * A dev build has no `plugins.updater` in tauri.conf.json, so the plugin is never registered and + * `check()` fails with a raw plugin error. That is a sentence about the build, not a fault, and it + * reads as one. + */ +const NOT_ENABLED = "Updates are not enabled in this build."; + +/** How stale the last check has to be before an automatic one is worth making on launch. */ +const AUTOMATIC_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** + * How long after launch the automatic check waits. The first paint, the session restore and the + * first index pass all want the main thread and the network before anything asks GitHub a question + * nobody has asked for. + */ +const LAUNCH_DELAY_MS = 6_000; + +let offered: Update | null = null; + +/** + * Whether a rejection means "this build has no updater in it" rather than "the check failed". + * + * src-tauri/src/lib.rs registers the plugin only when the config it needs is present, so in a dev + * build there is nothing behind `plugin:updater|check` and Tauri rejects it from its own plugin + * store: `PluginStore::extend_api` formats exactly `plugin updater not found`. The ACL arrives at + * the same fact from the other side, ending its longer sentence with `Plugin not found`, for a + * build whose capability no longer grants `updater:default`. Both mean the same thing to somebody + * looking at the screen and neither is theirs to act on, so both become the one sentence above. + * + * Matched against the exact strings rather than against the word "updater", which also appears in + * every ordinary failure this function has to let through: a bad signature, an unreachable endpoint + * and a malformed manifest all name the plugin in their message. + */ +function updaterMissing(e: unknown): boolean { + const message = String(e); + return message === "plugin updater not found" || message.endsWith("Plugin not found"); +} + +async function runCheck(quiet: boolean): Promise { + // The updater and the process plugin are both desktop-only, in lib.rs and in the capability, so + // on a phone and in a browser this is a fact about the build rather than a command that failed. + if (!isDesktop) { + if (!quiet) notify(NOT_ENABLED); + return; + } + // Anything other than idle is a check already running or a dialog already answering the last one. + if (useUpdate.getState().phase !== "idle") return; + + useUpdate.getState().begin(); + try { + const update = await check(); + useUpdate.getState().markChecked(); + if (!update) { + useUpdate.getState().dismiss(); + if (!quiet) notify("Margin Docs is up to date"); + return; + } + offered = update; + useUpdate.getState().offer(update.version, update.body ?? null); + } catch (e) { + useUpdate.getState().dismiss(); + if (quiet) return; + notify(updaterMissing(e) ? NOT_ENABLED : `Could not check for updates: ${String(e)}`); + } +} + +/** + * The `check-updates` command, and the button in the settings panel. Says something either way: + * somebody who pressed it is owed an answer even when the answer is that there is nothing to do. + */ +export async function checkForUpdates(): Promise { + await runCheck(false); +} + +/** + * The same check, made on the app's own initiative and saying nothing unless there is an update to + * show. Off entirely when the user has turned the preference off, and skipped when the last check + * is recent enough that another one would only be noise. + * + * Returns its own cancel, so a shell that unmounts before the delay is up does not leave a check + * behind it. + */ +export function startUpdateChecks(): () => void { + const { automatic, lastChecked } = useUpdate.getState(); + if (!isDesktop || !automatic) return () => {}; + if (lastChecked !== null && Date.now() - lastChecked < AUTOMATIC_INTERVAL_MS) return () => {}; + const timer = window.setTimeout(() => void runCheck(true), LAUNCH_DELAY_MS); + return () => window.clearTimeout(timer); +} + +/** + * Downloads the update the dialog is showing and restarts into it. + * + * The progress callback is the whole reason this is not one line: `downloadAndInstall` reports + * every chunk and the dialog draws them, so a hundred megabytes over a slow connection is a bar + * moving rather than an app that has stopped answering. + * + * Nothing runs after `relaunch()`, which is why the flush is in front of it. The window close + * handler in src/App.tsx is what normally saves a buffer half a second old, and a relaunch does not + * go anywhere near it. + */ +export async function installUpdate(): Promise { + const update = offered; + if (update === null) return; + + let downloaded = 0; + let total: number | null = null; + useUpdate.getState().progress(0, null); + try { + await update.downloadAndInstall((event) => { + if (event.event === "Started") { + total = event.data.contentLength ?? null; + useUpdate.getState().progress(0, total); + } else if (event.event === "Progress") { + downloaded += event.data.chunkLength; + useUpdate.getState().progress(downloaded, total); + } else { + // The bytes are in and the bundle is being swapped over. There is no number for this part + // and it is not instant, so it is a phase rather than a bar sitting full. + useUpdate.getState().installing(); + } + }); + offered = null; + await flushPendingSave(); + await relaunch(); + } catch (e) { + useUpdate.getState().failed(String(e)); + } +} + +/** Later, Escape, or the close button. The handle goes with the dialog. */ +export function dismissUpdate(): void { + const update = offered; + offered = null; + useUpdate.getState().dismiss(); + if (update !== null) void update.close().catch(() => {}); +} + +/** + * What the running bundle says its version is, which is the number the updater compares against the + * manifest. Asked of Tauri rather than read out of package.json so that the settings panel cannot + * disagree with the app it is inside. Null in a browser, where there is no bundle to ask. + */ +export async function appVersion(): Promise { + if (!isTauri) return null; + try { + return await getVersion(); + } catch { + return null; + } +} diff --git a/tests/export-writes-only-the-pdf.spec.ts b/tests/export-writes-only-the-pdf.spec.ts new file mode 100644 index 0000000..131fa1d --- /dev/null +++ b/tests/export-writes-only-the-pdf.spec.ts @@ -0,0 +1,343 @@ +// An export writes the PDF and nothing else. +// +// The claim in src/export/run.ts is a claim about what an export does NOT do: it never calls +// `save`, never dispatches a transaction, and asks the document store for nothing but the path and +// the tree it is already holding. Every other suite reads that off the screen. This one reads it +// off the wire and off the fixture, the way tests/bytes.spec.ts does, because the promise is about +// bytes: choosing to export a document must not turn into writing over a file the user had not +// decided to write yet, and there is no undo for that on disk. +// +// The document exported here has an unsaved edit in it, which is the case that can actually hurt. +// A save on the way into an export would look harmless on a clean buffer and would silently commit +// half a paragraph on a dirty one. Writes are held through `external.pauseWrites` so the buffer +// stays dirty for as long as the export takes rather than racing the 500ms autosave, and the edit +// is let through afterwards to prove it was real: a file that did not change because the edit had +// evaporated would prove nothing at all. +// +// It also carries an image, a table, a formula and a mermaid fence, because those are the four +// blocks the converter does extra work for, and three of them put something in the compile that no +// other block does: the image travels as an absolute path for the backend to open, the diagram is +// drawn to SVG and travels as bytes, and the formula becomes a mitex call. The Typst source and the +// image list handed to `pdf_compile` are asserted here for exactly that reason. An export that +// wrote nothing because it never got past the first paragraph is the shape of green this suite is +// built to refuse. +// +// WHAT A BROWSER CANNOT REACH. `exportPdf` gates on `isDesktop`, which is `isTauri` and not a +// phone. `isTauri` reads `__TAURI_INTERNALS__` off the window and tests/disk.ts puts that there at +// document start, so the gate opens and the whole path runs: convert, draw, compile, panel, write. +// What is behind it is the dev fixture, so `pdf_compile` answers with a PDF header instead of a +// typeset document and `pdf_write` puts nothing anywhere. That the compiler produces a real PDF and +// that the write lands where it was pointed are Rust questions, and they are asked in +// src-tauri/tests/export_writes_only_the_pdf.rs. The one piece nothing can drive from either side +// is the native save panel itself: it is an AppKit window, so this spec answers the command for it +// and cannot say what a real panel would hand back for a name the user typed. That distinction +// matters, and the Rust suite is where it is followed up. + +import { expect, test, type Page } from "@playwright/test"; +import { putCaret } from "./caret"; +import { ask, change, installTauriShim } from "./disk"; + +const HANDBOOK = "/Users/you/Documents/Handbook"; +const README = `${HANDBOOK}/README.md`; +/** The picture the document points at, which the fixture really has. */ +const PICTURE = `${HANDBOOK}/reference/assets/diagram.png`; +/** Where the save panel is pointed: outside the folder, which is the ordinary case. */ +const TARGET = "/Users/you/Desktop/README.pdf"; + +const row = (path: string) => `.tree-row[data-path="${path}"]`; + +/** Longer than the 500ms autosave debounce in src/document.ts, with room for the write itself. */ +const SAVED = 1500; + +/** Mermaid is several megabytes and is fetched on the first diagram, so the first export is slow. */ +const EXPORTED = 20_000; + +/** + * One document holding all four of the blocks the converter does extra work for. Built by joining + * lines rather than as a template literal because a mermaid fence is three backticks. + */ +const SOURCE = [ + "# Quarterly report", + "", + "Prose before anything difficult.", + "", + "![the diagram](reference/assets/diagram.png)", + "", + "| region | total |", + "| ------ | ----- |", + "| north | 12 |", + "| south | 9 |", + "", + "$$", + "E = mc^2", + "$$", + "", + "```mermaid", + "graph TD", + " A[Start] --> B[Finish]", + "```", + "", + "Closing line.", + "", +].join("\n"); + +/** One command as it crossed the boundary, with the fields worth keeping off the ones that have them. */ +interface Sent { + command: string; + /** The destination, for the two commands that name one: `file_write` and `pdf_write`. */ + path: string | null; + /** The Typst source, for the one command that carries it. */ + source: string | null; + /** What the compile was handed to open, and whether the bytes came with it or are on disk. */ + images: { path: string; inline: boolean }[] | null; +} + +/** One `listen` the app has registered: which event, and the callback id the shim gave it. */ +interface Listen { + event: string; + id: number; +} + +interface Internals { + invoke: (command: string, args?: Record) => unknown; + runCallback: (id: number, data: unknown) => void; +} + +declare global { + interface Window { + __sent: Sent[]; + __listens: Listen[]; + /** What the save panel answers with. `null` is the user pressing Cancel. */ + __savePanel: string | null; + __TAURI_INTERNALS__: Internals; + } +} + +/** + * Records every command the app sends, and answers the one a browser has no window for. + * + * Added after `installTauriShim`, which puts `__TAURI_INTERNALS__` on the window synchronously. + * Wrapping that object's `invoke` rather than the app's own wrapper is what makes this a + * measurement of what was sent rather than of what src/api/pdf.ts meant to send: a `file_write` + * from anywhere in the app, on any path, lands in this list. + */ +function recordIpc(): void { + window.__sent = []; + window.__listens = []; + window.__savePanel = null; + const internals = window.__TAURI_INTERNALS__; + const real = internals.invoke; + internals.invoke = (command, args) => { + const images = (args?.images as { path: string; data: string | null }[] | undefined) ?? null; + window.__sent.push({ + command, + path: (args?.path as string) ?? null, + source: (args?.source as string) ?? null, + images: images === null ? null : images.map((i) => ({ path: i.path, inline: i.data !== null })), + }); + if (command === "plugin:event|listen") { + window.__listens.push({ event: args?.event as string, id: args?.handler as number }); + } + // The save panel is an AppKit window. This is the one thing in the whole path a browser cannot + // have, so it is answered here and everything either side of it is the running program. + if (command === "plugin:dialog|save") return Promise.resolve(window.__savePanel); + return real(command, args); + }; +} + +/** Opens the README with the given bytes in it and waits for them to be on screen. */ +async function open(page: Page, source: string): Promise { + await page.addInitScript(installTauriShim); + await page.addInitScript(recordIpc); + await page.addInitScript(() => { + localStorage.clear(); + localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"])); + }); + await page.goto("/"); + await expect(page.locator(row(HANDBOOK))).toBeVisible(); + await page.locator(row(README)).click(); + await expect(page.locator(".prose")).toBeVisible(); + + // Written from outside rather than typed, so the app has not registered a self-write and this is + // a file it has only ever read. + await change(page, "write", README, source); + await expect.poll(() => ask(page, "read", README)).toBe(source); +} + +/** What is on disk now. */ +const disk = (page: Page, path = README): Promise => ask(page, "read", path); + +const sent = (page: Page): Promise => page.evaluate(() => window.__sent); + +const commands = async (page: Page): Promise => + (await sent(page)).map((call) => call.command); + +const toast = (page: Page) => page.locator(".toast"); + +/** + * The gesture, which is the File menu and not a call to `exportPdf`. + * + * The same route tests/writing-tools.spec.ts takes and for the same reason: src-tauri/src/lib.rs + * answers its own row by emitting `menu-action` with the command id, App.tsx has a `listen` on that + * event, and src/keys/menu.ts turns the payload into a command. Everything from the event on is the + * running program. + */ +async function runFromMenu(page: Page, command: string): Promise { + const delivered = await page.evaluate((id) => { + let count = 0; + for (const subscription of window.__listens) { + if (subscription.event !== "menu-action") continue; + window.__TAURI_INTERNALS__.runCallback(subscription.id, { + event: "menu-action", + id: subscription.id, + payload: id, + }); + count += 1; + } + return count; + }, command); + // An event nobody is subscribed to is the shape of test that passes because nothing happened. + expect(delivered, "the app has no menu-action listener to fire at").toBeGreaterThan(0); +} + +/** + * Types a character into the first paragraph and holds the save that would follow, so the buffer is + * dirty and the file is not, for as long as the caller needs. Answers with the log as it stood the + * moment the buffer settled, which is the line everything after it is measured from. + */ +async function dirtyWithHeldSave(page: Page): Promise { + await ask(page, "pauseWrites"); + + const paragraph = page.locator(".prose p").first(); + await paragraph.click(); + await putCaret(paragraph, "end"); + await page.keyboard.type("Z"); + + await expect(page.locator(".dirty-dot")).toBeVisible(); + // The autosave has fired and is sitting in the gate. Waiting for it rather than for a timeout is + // what makes the count below a line the export is measured against instead of a race with it. + await expect + .poll(async () => (await commands(page)).filter((c) => c === "file_write").length) + .toBe(1); + + return (await sent(page)).length; +} + +test("an export of a document with unsaved edits writes nothing but the PDF", async ({ page }) => { + test.setTimeout(60_000); + await open(page, SOURCE); + + // All four of the blocks the converter does extra work for are really on screen, so the export + // below is the export this test is about and not a page of prose that happens to be green. + // Not every `img` under `.prose` is the document's: prosemirror-view puts a zero width + // `ProseMirror-separator` after an inline node that ends a textblock, and it is one too. + await expect(page.locator(".prose img:not(.ProseMirror-separator)")).toHaveCount(1); + await expect(page.locator(".prose table")).toHaveCount(1); + await expect(page.locator(".prose [data-math-block]")).toHaveCount(1); + await expect(page.locator(".prose .mermaid-block")).toHaveCount(1); + + const line = await dirtyWithHeldSave(page); + const before = await page.locator(".prose").innerHTML(); + await page.evaluate((target) => { + window.__savePanel = target; + }, TARGET); + + await runFromMenu(page, "export-pdf"); + await expect(toast(page)).toContainText("Exported README.pdf", { timeout: EXPORTED }); + + const during = (await sent(page)).slice(line); + + // 1. Exactly these commands crossed the boundary, in this order, and no other. `file_write` is + // not among them, and neither is `file_read`: the export asked the document store for the + // tree it was already holding and went to the compiler with it. + expect(during.map((call) => call.command)).toEqual([ + "plugin:event|listen", + "pdf_compile", + "plugin:event|unlisten", + "plugin:dialog|save", + "pdf_write", + ]); + + // 2. The bytes went where the panel pointed, and nowhere else. + expect(during.find((call) => call.command === "pdf_write")?.path).toBe(TARGET); + + // 3. The file on disk is the one that was read, byte for byte. This is the whole test. + expect(await disk(page)).toBe(SOURCE); + + // 4. And the buffer is still dirty, which is the other half of it: an export that had quietly + // saved on the way past would leave a clean buffer and an unchanged-looking file, and the + // unchanged-looking file would be the one with the edit in it. The document on screen is the + // one that went in, too: `documentToTypst` is handed the live tree rather than a copy, so a + // converter that mutated it, or a transaction that changed the document on the way past, + // would show up here as a paragraph that is not the one that was exported. + await expect(page.locator(".dirty-dot")).toBeVisible(); + expect(await page.locator(".prose").innerHTML()).toBe(before); + + // 5. The four paths did their extra work. The image travels as the absolute path the backend + // opens under the root guard; the diagram was drawn and travels as bytes because it has no + // file behind it; the formula is a mitex call; the table is a Typst table. A compile handed + // none of that would have written nothing either, and would have proved nothing. + const compile = during.find((call) => call.command === "pdf_compile"); + expect(compile?.images).toEqual([ + { path: PICTURE, inline: false }, + { path: "/inline/diagram-1.svg", inline: true }, + ]); + expect(compile?.source).toContain(PICTURE); + expect(compile?.source).toContain("#table("); + expect(compile?.source).toContain("mitex"); + + // 6. The edit was real all along. Letting the held save through writes the character that was + // typed, so the file that did not move during the export was a file with a pending edit + // against it rather than a buffer that had lost one. + await ask(page, "resumeWrites"); + await expect.poll(() => disk(page)).toContain("difficult.Z"); + expect(await disk(page)).not.toBe(SOURCE); + + // 7. One write for the whole session, which is the autosave's, and it went out before the export + // started rather than because of it. + // + // What this cannot see, said plainly rather than left for the next person to assume. Holding + // the write is what keeps the buffer dirty for the length of an export, and it is also the + // one state in which a fire and forget `void save()` inside `exportPdf` would leave no trace: + // `saveNow` in src/document.ts folds a request that arrives while a write is on the wire into + // a single next lap, and by the time the gate opens that lap finds the buffer clean and skips. + // Measured, by putting that line into the module and watching this test stay green. An + // `await`ed one is caught, loudly, because the export then never reaches the panel at all. + // The reason the gap is narrow rather than alarming is the debounce itself: the buffer can + // only be dirty while a save is pending or in flight, so an export that saved would be + // writing the bytes the debounce was about to write anyway. What is worth proving is that the + // export adds no write of its own, and that is what steps 1 to 6 are. + await page.waitForTimeout(SAVED); + expect((await commands(page)).filter((c) => c === "file_write")).toEqual(["file_write"]); +}); + +test("cancelling the save panel leaves the document exactly where it was", async ({ page }) => { + // The branch that returns halfway through, after a compile and before a write. It is worth its + // own test because it is the one place an export ends without a toast to say so, and a tidy-up + // on that path that reached for `save` would be invisible on screen and permanent on disk. + test.setTimeout(60_000); + await open(page, SOURCE); + await expect(page.locator(".prose .mermaid-block")).toHaveCount(1); + + const line = await dirtyWithHeldSave(page); + // Left as null, which is what the panel answers when the user presses Cancel. + + await runFromMenu(page, "export-pdf"); + await expect + .poll(async () => (await commands(page)).filter((c) => c === "plugin:dialog|save").length, { + timeout: EXPORTED, + }) + .toBe(1); + + const during = (await sent(page)).slice(line); + expect(during.map((call) => call.command)).toEqual([ + "plugin:event|listen", + "pdf_compile", + "plugin:event|unlisten", + "plugin:dialog|save", + ]); + + expect(await disk(page)).toBe(SOURCE); + await expect(page.locator(".dirty-dot")).toBeVisible(); + await expect(toast(page)).toHaveCount(0); +}); diff --git a/tests/writing-tools.spec.ts b/tests/writing-tools.spec.ts new file mode 100644 index 0000000..86255f4 --- /dev/null +++ b/tests/writing-tools.spec.ts @@ -0,0 +1,590 @@ +// Writing Tools, which nothing in this suite can actually run, and the two halves of it that can +// still be proved here. +// +// The feature needs macOS 15.1 with Apple Intelligence turned on, and what it does when it runs is +// not a call this app makes: the system finds the selection in the webview and rewrites it by +// mutating the DOM, and the first this app hears of it is prosemirror-view reparsing its own +// document. So the risk is not "does the menu item fire". It is what those bytes become, and that +// is reproducible in Chromium without a single line of AppKit, because the mutation is an ordinary +// DOM edit: take the range out, put text in. +// +// The first half of this file does exactly that and then reads the file off the fixture, the way +// tests/bytes.spec.ts does, because the promise being made is about the file rather than about the +// screen. The second half drives the gesture, the Edit menu row, and asserts what +// src/editor/writing.ts refuses and that a refusal really did stop the call. A guard asserted by +// calling it would prove nothing about whether the running program asks it, and this project has +// shipped four that did not. +// +// What is deliberately NOT claimed here: that the mutation below is byte for byte the one macOS +// makes. Nobody outside Apple can promise that. It is the worst plausible shape of it, a range +// deleted and replaced, which is what a browser does for any programmatic text replacement, and a +// guard drawn against the worst shape is a guard that holds for the gentler ones. + +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { putCaret, settle, type Where } from "./caret"; +import { ask, change, installTauriShim } from "./disk"; + +const HANDBOOK = "/Users/you/Documents/Handbook"; +const README = `${HANDBOOK}/README.md`; +const NOTES = `${HANDBOOK}/notes.txt`; + +const row = (path: string) => `.tree-row[data-path="${path}"]`; + +/** Longer than the 500ms autosave debounce in src/document.ts, with room for the write itself. */ +const SAVED = 1500; + +interface WritingCall { + command: string; + tool: string | null; +} + +/** One `listen` the app has registered: which event, and the callback id the shim gave it. */ +interface Subscription { + event: string; + id: number; +} + +interface Internals { + invoke: (command: string, args?: Record) => unknown; + runCallback: (id: number, data: unknown) => void; +} + +declare global { + interface Window { + __writing: WritingCall[]; + __listening: Subscription[]; + __TAURI_INTERNALS__: Internals; + } +} + +/** + * Records what crosses the IPC boundary: every writing_* command, so a refusal can be shown to have + * stopped one, and every event the app subscribes to, so the menu can be fired at it below. + * + * Added after `installTauriShim`, which puts `__TAURI_INTERNALS__` on the window synchronously. + * Wrapping that object's `invoke` rather than the app's own wrapper is what makes this a + * measurement of what was sent rather than of what src/api/writing.ts meant to send. + */ +function recordIpc(): void { + window.__writing = []; + window.__listening = []; + const internals = window.__TAURI_INTERNALS__; + const real = internals.invoke; + internals.invoke = (command, args) => { + if (command.startsWith("writing_")) { + window.__writing.push({ command, tool: (args?.tool as string) ?? null }); + } + if (command === "plugin:event|listen") { + window.__listening.push({ event: args?.event as string, id: args?.handler as number }); + } + return real(command, args); + }; +} + +interface OpenOptions { + /** The file to open. The README unless a test wants the .txt surface. */ + path?: string; + /** Sets the fixture's own switch for a Mac with no Writing Tools menu. */ + unavailable?: boolean; +} + +/** Opens a document with the given bytes in it and waits for them to be on screen. */ +async function open(page: Page, source: string, options: OpenOptions = {}): Promise { + const path = options.path ?? README; + await page.addInitScript(installTauriShim); + await page.addInitScript(recordIpc); + await page.addInitScript((unavailable) => { + localStorage.clear(); + localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"])); + if (unavailable) localStorage.setItem("margindocs-dev-no-writing-tools", "1"); + }, options.unavailable === true); + await page.goto("/"); + await expect(page.locator(row(HANDBOOK))).toBeVisible(); + await page.locator(row(path)).click(); + await expect(page.locator(path === NOTES ? "textarea.plain-text" : ".prose")).toBeVisible(); + + // Written from outside rather than typed, so the app has not registered a self-write and this is + // a file it has only ever read. + await change(page, "write", path, source); + await expect.poll(() => ask(page, "read", path)).toBe(source); +} + +/** What is on disk now. */ +const disk = (page: Page, path = README): Promise => ask(page, "read", path); + +const calls = (page: Page): Promise => page.evaluate(() => window.__writing); + +const commands = async (page: Page): Promise => + (await calls(page)).map((call) => call.command); + +/** + * What the system does to the selection, as far as anything outside Apple can say: the range comes + * out and one run of text goes in. No input event, no ProseMirror transaction, nothing the editor + * was asked about first. prosemirror-view's own MutationObserver is what notices. + */ +async function rewriteRange( + page: Page, + selector: string, + from: number, + to: number, + text: string, + end = selector, +): Promise { + await page.evaluate( + (job) => { + const at = (selector: string, offset: number): [Node, number] => { + const host = document.querySelector(selector); + if (!host) throw new Error(`nothing matches ${selector}`); + const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT); + let seen = 0; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (offset <= seen + node.data.length) return [node, offset - seen]; + seen += node.data.length; + } + throw new Error(`offset ${offset} is past the end of ${selector}`); + }; + + const range = document.createRange(); + const [startNode, startOffset] = at(job.selector, job.from); + const [endNode, endOffset] = at(job.end, job.to); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + range.deleteContents(); + range.insertNode(document.createTextNode(job.text)); + }, + { selector, from, to, text, end }, + ); + await settle(page); +} + +/** A selection a person could have made: a caret, then Shift and the arrow key, held down. */ +async function selectFrom(target: Locator, at: Where, characters: number): Promise { + const page = target.page(); + await target.click(); + await putCaret(target, at); + for (let i = 0; i < characters; i += 1) await page.keyboard.press("Shift+ArrowRight"); + await settle(page); +} + +/** The blocks the two ends of the live selection are in, named by tag. */ +function selectionBlocks(page: Page): Promise { + return page.evaluate(() => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return []; + const range = selection.getRangeAt(0); + const block = (node: Node): string => { + const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; + return ( + element?.closest("p, h1, h2, h3, h4, h5, h6, td, th, pre, [data-math-block]")?.tagName ?? "?" + ); + }; + return [block(range.startContainer), block(range.endContainer)]; + }); +} + +/** + * The gesture, which is the Edit menu and not a call to `runWritingTool`. + * + * A native menu cannot be clicked from a browser, but everything downstream of the click can be + * driven exactly as it happens: src-tauri/src/lib.rs answers its own row by emitting `menu-action` + * with the command id, App.tsx has a `listen` on that event, and src/keys/menu.ts turns the payload + * into a command. So this delivers that event to the app's own subscriber through the shim's + * callback registry, and everything from there on is the running program. It is also the one path + * that leaves the selection alone, which is why it is the path this feature is built around: a + * native menu does not take the keyboard off the webview. + */ +async function runFromMenu(page: Page, command: string): Promise { + const delivered = await page.evaluate((id) => { + let count = 0; + for (const subscription of window.__listening) { + if (subscription.event !== "menu-action") continue; + window.__TAURI_INTERNALS__.runCallback(subscription.id, { + event: "menu-action", + id: subscription.id, + payload: id, + }); + count += 1; + } + return count; + }, command); + // An event nobody is subscribed to is the shape of test that passes because nothing happened. + expect(delivered, "the app has no menu-action listener to fire at").toBeGreaterThan(0); +} + +/** The other way in, and the one that cannot preserve a selection. */ +async function runFromPalette(page: Page, label: string): Promise { + await page.keyboard.press("Meta+k"); + await page.locator(".palette-field").fill(label); + // The row about to run is the row this test means, rather than whatever fuzzy matching put at the + // top today. + await expect(page.locator(".palette-row").first()).toContainText(label); + await page.keyboard.press("Enter"); +} + +const REWRITE = "writing-rewrite"; +const PROOFREAD = "writing-proofread"; + +const toast = (page: Page): Locator => page.locator(".toast"); + +// --------------------------------------------------------------------------------------------- +// What the bytes do. No guard involved: this is the ground the guard below is drawn on. +// --------------------------------------------------------------------------------------------- + +test("a rewrite across a hand wrapped paragraph leaves no backslash behind", async ({ page }) => { + // The one that would matter most, because a hand wrapped file is what a text editor leaves + // behind and a rewrite is asked for on whole sentences. The paragraph is declared + // `whitespace: "pre"` in src/model/schema.ts so prosemirror-view reparses the wraps as newlines + // rather than as hard breaks; without that field the wraps the rewrite did not touch would come + // back as `\` at the end of every line, which is the bug docs/architecture.md is about. + await open(page, "alpha beta\ngamma delta\n"); + + await rewriteRange(page, ".prose p", 0, 22, "One rewritten sentence."); + await page.waitForTimeout(SAVED); + + expect(await disk(page)).toBe("One rewritten sentence.\n"); +}); + +test("a rewrite that only covers the first wrapped line leaves the second one alone", async ({ + page, +}) => { + await open(page, "alpha beta\ngamma delta\n"); + + await rewriteRange(page, ".prose p", 0, 10, "Alpha Beta"); + await page.waitForTimeout(SAVED); + + expect(await disk(page)).toBe("Alpha Beta\ngamma delta\n"); +}); + +test("a rewrite inside a heading is still a heading, and inside a list item still an item", async ({ + page, +}) => { + await open(page, "# Title Here\n\n- one\n- two\n"); + + await rewriteRange(page, ".prose h1", 0, 10, "Another Title"); + await rewriteRange(page, ".prose li p", 0, 3, "ONE rewritten"); + await page.waitForTimeout(SAVED); + + expect(await disk(page)).toBe("# Another Title\n\n- ONE rewritten\n- two\n"); +}); + +test("markdown the rewrite invents is escaped rather than obeyed", async ({ page }) => { + // A rewrite is prose from a language model and it will happily open a sentence with "1." or "-". + // Those are the file's own syntax, so if they went in raw the paragraph would come back as a list + // the next time the file was read, which is a block the user never asked for. + await open(page, "plain sentence\n"); + + await rewriteRange(page, ".prose p", 0, 14, "- a list? *maybe* [really](no)"); + await page.waitForTimeout(SAVED); + + expect(await disk(page)).toBe("\\- a list? \\*maybe\\* \\[really]\\(no)\n"); + await expect(page.locator(".prose ul")).toHaveCount(0); +}); + +test("a rewrite whose ends are in two paragraphs damages both of them", async ({ page }) => { + // The evidence for the guard, reproduced with the guard out of the way. Two paragraphs become + // three, and ` ` lands in the middle of words nobody selected: the serializer's honest + // spelling for a leading space markdown has no other way to keep, in a place no author would + // have put one. Nothing here is a serializer bug. It is the wrong range to hand a rewrite. + await open(page, "one alpha\n\ntwo beta\n"); + + await rewriteRange(page, ".prose p:nth-of-type(1)", 4, 3, "MERGED", ".prose p:nth-of-type(2)"); + await page.waitForTimeout(SAVED); + + expect(await disk(page)).toBe("one \n\nMERGED\n\n beta\n"); +}); + +test("a rewrite whose ends are in two table cells is not a table any more", async ({ page }) => { + // The worst of them. GFM has one header row and every row has to be the same width; this comes + // back a column wider with an empty first cell in both rows, so the file now says something about + // the user's data that the user did not. + await open(page, "| a | b |\n| - | - |\n| c | d |\n"); + + await rewriteRange(page, ".prose td:nth-of-type(1)", 0, 1, "X", ".prose td:nth-of-type(2)"); + await page.waitForTimeout(SAVED); + + expect(await disk(page)).toBe("| | a | b |\n| - | - | - |\n| | X | |\n"); +}); + +test("a rewrite across a link takes the address with it, and one inside the link does not", async ({ + page, +}) => { + // Both halves in one test because the pair is the rule. The address is not in the words on + // screen, so a range that swallows the whole anchor loses something the user cannot see they had. + // A range wholly inside the anchor is safe: the mark is an ancestor of the range rather than + // content the range can delete, so the destination is still there afterwards. + await open(page, "see [docs page](x.md) now\n"); + + await rewriteRange(page, ".prose a", 0, 9, "the guide"); + await page.waitForTimeout(SAVED); + expect(await disk(page)).toBe("see [the guide](x.md) now\n"); + + await rewriteRange(page, ".prose p", 0, 13, "rewritten"); + await page.waitForTimeout(SAVED); + expect(await disk(page)).toBe("rewritten now\n"); +}); + +// --------------------------------------------------------------------------------------------- +// The guard, through the gesture. +// --------------------------------------------------------------------------------------------- + +test("an ordinary sentence is handed to the system", async ({ page }) => { + // The control. Without it every refusal below would still pass on a build where nothing ever + // reaches the system at all, which is the same green as a guard that works. + await open(page, "just a plain sentence here\n"); + + await selectFrom(page.locator(".prose p"), 0, 4); + await runFromMenu(page, REWRITE); + + await expect.poll(() => commands(page)).toEqual(["writing_available", "writing_run"]); + expect((await calls(page))[1].tool).toBe("Rewrite"); + await expect(toast(page)).toHaveCount(0); +}); + +test("Proofread asks for the item the system calls Proofread", async ({ page }) => { + await open(page, "just a plain sentence here\n"); + + await selectFrom(page.locator(".prose p"), 0, 4); + await runFromMenu(page, PROOFREAD); + + await expect.poll(() => commands(page)).toEqual(["writing_available", "writing_run"]); + expect((await calls(page))[1].tool).toBe("Proofread"); +}); + +test("a selection covering two paragraphs is refused and nothing is asked", async ({ page }) => { + await open(page, "one alpha\n\ntwo beta\n"); + + await selectFrom(page.locator(".prose p").first(), 4, 8); + // The gesture really did cross the boundary. Without this the test passes on a build where + // Shift+ArrowRight quietly stopped at the end of the paragraph, and the refusal below would be + // answering a question nobody asked. + expect(await selectionBlocks(page)).toEqual(["P", "P"]); + expect( + await page.evaluate(() => { + const range = window.getSelection()!.getRangeAt(0); + const block = (node: Node) => + (node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement)?.closest("p"); + return block(range.startContainer) === block(range.endContainer); + }), + ).toBe(false); + + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("one paragraph at a time"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("a selection covering a link is refused and nothing is asked", async ({ page }) => { + await open(page, "see [docs](x.md) now\n"); + + await selectFrom(page.locator(".prose p"), 0, 8); + await expect + .poll(() => + page.evaluate(() => window.getSelection()!.getRangeAt(0).cloneContents().querySelector("a") !== null), + ) + .toBe(true); + + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("address"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("a selection covering a picture or a formula is refused", async ({ page }) => { + // Both are atoms: a range that covers one deletes it, and neither the file name nor the LaTeX is + // in the words on screen for a rewrite to put back. Inline maths is `$$…$$` here, not `$…$`: + // src/markdown/handlers.ts turns single dollar text maths off, so a lone `$` is a dollar sign. + await open(page, "see ![pic](p.png) now\n\nand $$x+1$$ too\n"); + + await selectFrom(page.locator(".prose p").first(), 0, 6); + await runFromMenu(page, REWRITE); + await expect(toast(page)).toContainText("picture"); + + await selectFrom(page.locator(".prose p").nth(1), 0, 6); + await runFromMenu(page, REWRITE); + await expect(toast(page)).toContainText("formula"); + + await expect.poll(() => commands(page)).toEqual([ + "writing_available", + "writing_available", + ]); +}); + +test("a selection wholly inside a code span is refused", async ({ page }) => { + // The bytes survive this one, which is exactly why it needs its own test: the range is inside the + // `code` element rather than covering it, so `cloneContents` reports nothing and the refusal + // comes from the marks around the range instead. + await open(page, "a `some code` b\n"); + + await selectFrom(page.locator(".prose code"), 0, 4); + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("code"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("with no document open it says to open one", async ({ page }) => { + await page.addInitScript(installTauriShim); + await page.addInitScript(recordIpc); + await page.addInitScript(() => { + localStorage.clear(); + localStorage.setItem("margindocs-recents", JSON.stringify(["/Users/you/Documents/Handbook"])); + }); + await page.goto("/"); + await expect(page.locator(row(HANDBOOK))).toBeVisible(); + await expect(page.locator(".prose")).toHaveCount(0); + + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("Open a document"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("a selection inside a fenced code block is refused and nothing is asked", async ({ page }) => { + // Not a byte argument: a fence round trips a rewrite cleanly. It is the argument + // src/editor/proofing.ts already made for the spell checker, which is that a tool rewriting + // somebody's variable names is a tool they turn off, and a rewriter is the louder version of it. + await open(page, "```js\nconst a = 1;\n```\n"); + + await selectFrom(page.locator(".prose pre code"), 0, 5); + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("code"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); + expect(await disk(page)).toBe("```js\nconst a = 1;\n```\n"); +}); + +test("a selection inside a raw block is refused and nothing is asked", async ({ page }) => { + // A raw block is the file's own bytes, held twice so the editor can prove it has not touched + // them. Handing them to a rewriter is the one thing they must never be handed to. + await open(page, 'para\n\n
raw
\n'); + + await selectFrom(page.locator(".prose pre.raw-block code"), 0, 5); + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("the file's own bytes"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); + expect(await disk(page)).toBe('para\n\n
raw
\n'); +}); + +test("the cursor in a formula's source is refused, and the message says so", async ({ page }) => { + // A formula is not prose and its field is not the document: it is a textarea of its own inside + // it, so it takes the keyboard away and the general answer would be to click into the document. + // That answer is true and unhelpful, hence the branch and hence this test, which is here because + // a refusal nothing can reach is the shape of guard this project has shipped four times. + const source = "before\n\n$$\ny = 2\n$$\n"; + await open(page, source); + + await page.locator(".prose .math-block").click(); + await expect(page.locator(".prose textarea.math-source")).toBeFocused(); + + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("formula"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("a drag across a rendered formula is refused, and the message says so", async ({ page }) => { + // The other way into a display equation, and the one the cursor never enters: the block is + // `contenteditable=false`, so clicking it opens the source field above, but a drag across the + // rendered maths still leaves a real selection with both ends inside the block and the document + // holding the keyboard. A mouse drag because that is the only gesture that makes one. + await open(page, "before\n\n$$\ny = 2 + 3 + 4\n$$\n\nafter\n"); + + const box = await page.locator(".prose .math-render").boundingBox(); + await page.mouse.move(box!.x + 4, box!.y + box!.height / 2); + await page.mouse.down(); + await page.mouse.move(box!.x + box!.width - 4, box!.y + box!.height / 2, { steps: 10 }); + await page.mouse.up(); + // The drag really made a selection inside the block, rather than a caret somewhere near it. + expect(await page.evaluate(() => window.getSelection()!.isCollapsed)).toBe(false); + expect(await selectionBlocks(page)).toEqual(["DIV", "DIV"]); + + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("formula"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("the cursor in a toggle's title is refused, and the message says so", async ({ page }) => { + // The same shape. A toggle's title is an attribute on the node rather than text in a block, and + // the span holding it is its own editable inside the document. + await open(page, "
\nSum\n\nbody\n\n
\n"); + + await page.locator(".prose [data-toggle-summary]").click(); + await expect(page.locator(".prose [data-toggle-summary]")).toBeFocused(); + + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("toggle"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("a caret with nothing selected is refused", async ({ page }) => { + await open(page, "hello there\n"); + + await page.locator(".prose p").click(); + await putCaret(page.locator(".prose p"), 3); + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("Select the text"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("a Mac with no Writing Tools menu says so and asks for nothing", async ({ page }) => { + await open(page, "just a plain sentence here\n", { unavailable: true }); + + await selectFrom(page.locator(".prose p"), 0, 4); + await runFromMenu(page, REWRITE); + + await expect(toast(page)).toContainText("Apple Intelligence"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("a .txt file has no markdown to lose, so its selection goes straight through", async ({ + page, +}) => { + await open(page, "some plain notes\n", { path: NOTES }); + + const field = page.locator("textarea.plain-text"); + await field.click(); + await field.press("Meta+a"); + expect( + await field.evaluate((e: HTMLTextAreaElement) => e.selectionEnd - e.selectionStart), + ).toBeGreaterThan(0); + + await runFromMenu(page, REWRITE); + + await expect.poll(() => commands(page)).toEqual(["writing_available", "writing_run"]); + await expect(toast(page)).toHaveCount(0); +}); + +test("the palette says which gesture to use, rather than rewriting a caret it moved itself", async ({ + page, +}) => { + // The palette runs its row and then closes on to ``, so by the time the command reaches + // src/editor/writing.ts the document holds neither the keyboard nor the selection: measured here + // rather than assumed, because the tempting fix, focusing the editor back, is what destroys the + // selection outright. prosemirror-view takes the collapsed one the panel left behind as the + // document's own, and nothing brings the user's back afterwards. So this path says so and stops. + await open(page, "just a plain sentence here\n"); + + await selectFrom(page.locator(".prose p"), 0, 4); + await runFromPalette(page, "Rewrite with Writing Tools"); + + await expect(toast(page)).toContainText("holds the keyboard"); + await expect.poll(() => commands(page)).toEqual(["writing_available"]); +}); + +test("running the tool writes nothing by itself", async ({ page }) => { + // The system's rewrite is an edit and dirties the buffer like any other. Asking for one must not. + const source = "# Title\n\none wrapped\nline here\n"; + await open(page, source); + + await selectFrom(page.locator(".prose p"), 0, 3); + await runFromMenu(page, REWRITE); + await expect.poll(() => commands(page)).toEqual(["writing_available", "writing_run"]); + await page.waitForTimeout(SAVED * 2); + + expect(await disk(page)).toBe(source); +});