--- /dev/null
+# Touying - AI Coding Agent Instructions
+
+## Project Overview
+
+Touying is a Typst package for creating presentation slides. It is a powerful alternative to LaTeX Beamer with better performance, achieved by avoiding `counter` and `context` for `#pause` animations.
+
+## Repository Layout
+
+```
+lib.typ # Entry point — re-exports src/exports.typ
+src/
+ core.typ # Animation engine, slide rendering
+ utils.typ # Shared helpers (merge-dicts, display-current-heading, …)
+ configs.typ # config-common, config-colors, config-page, config-info, …
+ exports.typ # Public API surface
+ components.typ # Reusable slide components (cols, …)
+themes/ # 6 built-in themes: simple, metropolis, dewdrop, university, aqua, stargazer
+examples/ # Theme usage examples (excluded from package)
+tests/
+ features/ # Feature regression tests (test.typ + ref/*.png)
+ themes/ # Theme regression tests
+ examples/ # Full-presentation tests
+typst.toml # Package manifest (name, version, compiler constraint)
+```
+
+## Development Workflow
+
+### Tools
+
+Three tools must be available in your environment (all installed by `.github/workflows/copilot-setup-steps.yml`):
+
+| Tool | Command | Purpose |
+|------|---------|---------|
+| Typst CLI | `typst` | Compile `.typ` files |
+| tytanic | `tt` | Visual regression test runner |
+| typstyle | `typstyle` | Official Typst code formatter |
+
+### Testing with tytanic
+
+```bash
+# Run the full test suite (compares rendered PNGs against ref/ images)
+tt run
+
+# Run only a subset of tests
+tt run tests/features/animation
+
+# Update reference images after intentional visual changes
+tt update
+
+# List all tests
+tt list
+```
+
+- tytanic automatically locates the project root via `typst.toml`
+- Test output goes to `tests/**/out/`, diffs to `tests/**/diff/`
+- Reference images are committed in `tests/**/ref/`
+- PPI for reference images: 72 (set in `typst.toml` under `[tool.tytanic]`)
+
+**When to run `tt update`**: After making intentional visual changes (e.g., layout, theme colors), regenerate the reference images with `tt update` and commit the updated `ref/*.png` files.
+
+### Formatting with typstyle
+
+**Always format modified `.typ` files before finalising a PR.** The CI enforces this via `git diff --exit-code`.
+
+```bash
+# Format one or more files in-place
+typstyle -i src/core.typ src/utils.typ
+
+# Format an entire directory recursively
+typstyle -i themes/
+
+# Format all .typ files in the project
+typstyle -i src/ themes/ tests/
+```
+
+### Compiling a single file (quick check)
+
+```bash
+# No build step required — Touying is a pure Typst package
+typst compile --root . tests/features/animation/test.typ /tmp/out.pdf
+```
+
+## Typst Language Essentials
+
+### Syntax Modes
+
+| Mode | Trigger | Example |
+|------|---------|---------|
+| Markup (default) | — | `*bold*`, `_italic_`, `= Heading` |
+| Code | `#` prefix | `#let x = 1`, `#if cond { … }` |
+| Math | `$…$` | `$x^2 + y$` |
+
+> **Always use Typst's native math syntax**, not LaTeX. Typst math is more intuitive.
+
+### Naming: kebab-case only
+
+All identifiers (variables, functions, parameters) use **kebab-case**:
+
+```typst
+#let my-variable = 42
+#let calculate-sum(a, b) = a + b
+```
+
+snake_case, camelCase, and PascalCase are **prohibited**.
+
+### Reserved identifiers to avoid shadowing
+
+Do not bind variables or parameters to Typst built-in names. Key ones to avoid:
+
+- **Types**: `none`, `auto`, `bool`, `int`, `float`, `str`, `content`, `function`, `array`, `dictionary`, `color`, `length`, `ratio`, `angle`, `stroke`, `alignment`, `direction`
+- **Layout**: `columns`, `grid`, `table`, `stack`, `box`, `block`, `place`, `align`, `pad`, `measure`, `layout`, `repeat`
+- **Text/lists**: `text`, `par`, `list`, `enum`, `item`, `quote`, `raw`, `strong`, `emph`
+- **Document**: `document`, `page`, `heading`, `outline`, `figure`, `footnote`
+- **Graphics**: `math`, `rect`, `circle`, `polygon`, `rotate`, `scale`, `fill`, `gradient`
+- **I/O**: `read`, `include`, `image`, `link`, `ref`, `cite`
+
+When wrapping these, use a `touying-` prefix: `touying-grid`, `touying-table`.
+
+### Pure functions & caching
+
+Typst functions are **pure** — they cannot mutate external state:
+
+```typst
+// ✗ Invalid: cannot push to an external array inside a function
+// ✗ Invalid: cannot assign to an external variable
+
+// ✓ Return new values instead
+#let add-item(items, new) = items + (new,)
+```
+
+Typst automatically **caches** function call results by input. Avoid creating many tiny functions with trivial bodies — inline code is usually preferable to prevent cache bloat.
+
+## Key API Patterns
+
+### Slide creation
+
+```typst
+// Heading-based (preferred)
+= Section // section slide
+== Subsection // subsection slide
+=== Slide title // content slide
+
+// Explicit function call
+#slide[Content]
+#slide(composer: (1fr, 1fr))[Left column][Right column]
+```
+
+### Animation
+
+```typst
+#pause // incremental reveal
+#meanwhile // parallel track with #pause
+#uncover("2-")[content] // visible from subslide 2 onward
+#only("1,3")[content] // visible only on subslides 1 and 3
+#alternatives[Option A][Option B]
+
+// Callback style (access self.subslide)
+#slide(self => {
+ let (uncover, only, alternatives) = utils.methods(self)
+ uncover("2-")[Revealed on 2+]
+})
+```
+
+### Configuration
+
+```typst
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-info(title: [Title], author: [Author]),
+ config-colors(primary: blue),
+ config-common(handout: false),
+)
+
+// Per-slide config override
+#slide(
+ config: utils.merge-dicts(
+ config-colors(primary: red),
+ config-common(handout: true),
+ )
+)[Content]
+```
+
+Configuration functions:
+- `config-colors` — color scheme
+- `config-common` — global flags (`handout`, `frozen-counters`, `show-only-notes`, …)
+- `config-info` — document metadata (title, author, date, …)
+- `config-methods` — animation method overrides
+- `config-page` — page layout
+
+### External package integration (touying-reducer)
+
+```typst
+// CeTZ
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+#cetz-canvas({
+ import cetz.draw: *
+ rect((0,0), (5,5))
+ (pause,)
+ circle((2.5,2.5), radius: 1)
+})
+
+// Fletcher
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+```
+
+### Speaker notes
+
+```typst
+#speaker-note[
+ + Remind audience of previous slide
+ + Key takeaway: …
+]
+```
+
+## Documentation Standard
+
+Use `///` docstrings for all public functions:
+
+```typst
+/// One-line description.
+///
+/// Example:
+/// ```typst
+/// #my-fn(arg1: value)
+/// ```
+///
+/// - arg1 (str): Description.
+/// - arg2 (int | none): Optional count, defaults to `none`.
+///
+/// -> content
+```
+
+Types use Typst names (`str`, `int`, `content`, `function`, …); union types use `|`.
+
+## Special Heading Labels
+
+Touying recognises these heading labels for slide control:
+
+| Label | Effect |
+|-------|--------|
+| `<touying:hidden>` | Hide slide completely |
+| `<touying:skip>` | Skip slide in output |
+| `<touying:unnumbered>` | No slide number |
+| `<touying:unoutlined>` | Exclude from outline |
+| `<touying:unbookmarked>` | No PDF bookmark |
+| `<touying:handout>` | Show only in handout mode |
+
+## Performance Notes
+
+- Avoid `counter` and `context` inside animation logic (causes recompilation).
+- Use `touying-reducer` for external animated content (CeTZ, Fletcher, …).
+- Prefer functional composition over mutable state.
+- Only extract helpers into named functions when the caching benefit is real.
\ No newline at end of file
--- /dev/null
+name: "Copilot Setup Steps"
+
+# Automatically run the setup steps when they are changed to allow for easy validation,
+# and allow manual testing through the repository's "Actions" tab.
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - .github/workflows/copilot-setup-steps.yml
+ pull_request:
+ paths:
+ - .github/workflows/copilot-setup-steps.yml
+
+jobs:
+ # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
+ copilot-setup-steps:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Install the Typst CLI (used for compiling .typ files and running tests)
+ - name: Setup Typst
+ uses: typst-community/setup-typst@v4
+
+ # Install Rust toolchain (needed by cargo-binstall and tytanic)
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ # cargo-binstall provides fast binary installation without full compilation
+ - name: Install cargo-binstall
+ uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-binstall
+
+ # tytanic (tt) is the visual regression test runner for Touying
+ # Pinned to the same version used in test.yml CI
+ - name: Install tytanic
+ run: cargo binstall tytanic@0.3.3 -y
+
+ # typstyle is the official Typst code formatter
+ # Usage: typstyle -i file1.typ file2.typ dir/
+ - name: Install typstyle
+ run: cargo binstall typstyle -y
--- /dev/null
+name: Docs Preview
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, closed]
+ paths:
+ - "docs/**"
+
+# Build runs share one concurrency group and can cancel each other when a new
+# commit arrives. Cleanup (PR closed) uses a distinct group so it is never
+# cancelled by an in-progress build for the same PR.
+concurrency:
+ group: >-
+ docs-preview-${{ github.event.number }}-${{
+ github.event.action == 'closed' && 'cleanup' || 'build'
+ }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write # push to gh-pages branch
+ pull-requests: write # post / update the preview comment
+
+jobs:
+ preview:
+ name: Deploy Docs Preview
+ runs-on: ubuntu-latest
+
+ steps:
+ # ── 1. Check out this (touying) repo at the workspace root ─────────────
+ # The root checkout establishes GITHUB_TOKEN credentials so that
+ # rossjrw/pr-preview-action can push to *this* repo's gh-pages branch.
+ - name: Checkout touying (PR branch)
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ # ── 2. Clone the Docusaurus website repo ───────────────────────────────
+ # We clone it without initialising its submodule because we are about to
+ # replace the submodule directory with the current PR's content anyway.
+ - name: Checkout website repo
+ if: github.event.action != 'closed'
+ uses: actions/checkout@v4
+ with:
+ repository: touying-typ/touying-typ.github.io
+ path: website
+ submodules: false
+
+ # ── 3. Inject this PR's touying content into the website tree ──────────
+ # The website scripts expect:
+ # website/touying/docs/en/** (hand-written docs)
+ # website/touying/docs/zh/** (Chinese docs)
+ # website/touying/src/** (Typst sources – for generate-docs.py)
+ # website/touying/themes/** (theme files – for generate-docs.py)
+ # We rsync everything from the touying root except .git and the website/
+ # checkout directory itself to avoid recursion.
+ - name: Inject PR docs into website
+ if: github.event.action != 'closed'
+ run: |
+ mkdir -p website/touying
+ rsync -a --exclude='.git' --exclude='website' . website/touying/
+
+ # ── 4. Set up the Node.js environment ─────────────────────────────────
+ - name: Set up Node.js
+ if: github.event.action != 'closed'
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: website/package-lock.json
+
+ # ── 5. Install Typst (required by generate-images.py) ──────────────────
+ - name: Install Typst
+ if: github.event.action != 'closed'
+ uses: typst-community/setup-typst@v4
+
+ # ── 6. Install Python dependencies (Pillow for generate-images.py) ─────
+ - name: Install Python dependencies
+ if: github.event.action != 'closed'
+ run: pip install Pillow
+
+ # ── 7. Install Node dependencies ───────────────────────────────────────
+ - name: Install Node dependencies
+ if: github.event.action != 'closed'
+ working-directory: website
+ run: npm ci
+
+ # ── 8. Run the website's own doc-preparation pipeline ─────────────────
+ - name: Copy docs from touying into website structure
+ if: github.event.action != 'closed'
+ working-directory: website
+ run: npm run copy-docs
+
+ - name: Generate API reference documentation
+ if: github.event.action != 'closed'
+ working-directory: website
+ run: npm run generate-docs
+
+ - name: Generate slide preview images
+ if: github.event.action != 'closed'
+ id: generate-images
+ working-directory: website
+ run: npm run generate-images
+ continue-on-error: true
+
+ - name: Warn if slide image generation failed
+ if: steps.generate-images.outcome == 'failure'
+ run: |
+ echo "::warning::Slide preview image generation failed. The docs preview will be deployed without generated slide images. Check the 'Generate slide preview images' step log for details."
+
+ # ── 9. Patch Docusaurus config for PR preview ─────────────────────────
+ # The website navbar/footer may contain hardcoded links (e.g. /docs/start,
+ # /docs/dynamic/simple) that don't exist in every PR's docs structure.
+ # Downgrading onBrokenLinks from 'throw' to 'warn' prevents the build from
+ # failing over such link drift while still making broken links visible in
+ # the build log.
+ - name: Patch broken-links setting for PR preview
+ if: github.event.action != 'closed'
+ working-directory: website
+ run: |
+ for f in docusaurus.config.js docusaurus.config.ts docusaurus.config.mjs; do
+ if [ -f "$f" ]; then
+ sed -i "s/onBrokenLinks:[[:space:]]*['\"]throw['\"]/onBrokenLinks: 'warn'/g" "$f"
+ echo "Patched onBrokenLinks in $f"
+ break
+ fi
+ done
+
+ # ── 10. Build the Docusaurus site ──────────────────────────────────────
+ # The touying repo is hosted at https://touying-typ.github.io/touying/
+ # so PR previews live under /touying/pr-preview/pr-NNN/.
+ # We pass this as DOCUSAURUS_BASE_URL so that Docusaurus sets the
+ # correct <base> href and all asset / link paths resolve properly.
+ - name: Build website
+ if: github.event.action != 'closed'
+ working-directory: website
+ run: npm run build
+ env:
+ DOCUSAURUS_BASE_URL: /touying/pr-preview/pr-${{ github.event.number }}/
+
+ # ── 10. Deploy preview (or clean up on PR close) ────────────────────────
+ # rossjrw/pr-preview-action:
+ # - On open/sync/reopen: pushes build/ to the gh-pages branch under
+ # pr-preview/pr-NNN/ and posts a comment with the preview URL.
+ # - On close: removes pr-preview/pr-NNN/ from gh-pages and updates
+ # the comment.
+ #
+ # Preview URL: https://touying-typ.github.io/touying/pr-preview/pr-NNN/
+ - name: Deploy / clean-up preview
+ uses: rossjrw/pr-preview-action@v1
+ with:
+ source-dir: website/build
+ preview-branch: gh-pages
+ umbrella-dir: pr-preview
+ action: auto
--- /dev/null
+name: Test
+on:
+ push:
+ pull_request:
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install cargo-binstall
+ uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-binstall
+
+ - name: Install tytanic
+ run: cargo binstall tytanic@0.3.3 -y
+
+ - name: Setup typst
+ uses: typst-community/setup-typst@v4
+
+ - name: Run test suite
+ run: tt run
+
+ - name: Archive diffs
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: artifacts
+ path: |
+ tests/**/diff/*.png
+ tests/**/out/*.png
+ tests/**/ref/*.png
+ retention-days: 5
+
+ typstyle:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Run typstyle
+ uses: typstyle-rs/typstyle-action@main
+ - name: Check for changes
+ # Fails the CI job if typstyle modified any files.
+ # This ensures developers format their code before merging.
+ run: git diff --exit-code
--- /dev/null
+/local
+
+.DS_Store
+
+*.pdf
+*.pdfpc
+nohup.out
--- /dev/null
+Copyright (c) 2026 OrangeX4 <orangex4@qq.com>
+Copyright (c) 2026 zral0kh
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
--- /dev/null
+# 
+
+[Touying](https://github.com/touying-typ/touying) (投影 in Chinese, /tóuyǐng/, meaning projection) is a user-friendly, powerful, and efficient package for creating presentation slides in [Typst](https://typst.app/).
+
+If you like it, consider [giving a star ⭐ on GitHub](https://github.com/touying-typ/touying). Touying is a community-driven project — feel free to suggest ideas and contribute!
+
+[](https://typst.app/universe/package/touying)
+[](https://touying-typ.github.io/)
+[](https://deepwiki.com/touying-typ/touying)
+[](https://zread.ai/touying-typ/touying)
+[](https://github.com/touying-typ/touying/wiki)
+
+
+
+
+
+
+## Why Touying?
+
+- **Beautiful themes** — [built-in themes](https://touying-typ.github.io/themes/) like Simple, Metropolis, Dewdrop, University, Aqua, Stargazer and [diverse themes on Typst Universe](https://typst.app/universe/search/?q=touying)
+- **Fast** — Typst compiles in milliseconds. Live previews update as you type, giving you the instant feedback.
+- **Rich animations** — `#pause`, `#meanwhile`, math equation animations, CeTZ & Fletcher support
+- **Heading-based slides** — write presentations like a document, no boilerplate
+- **Speaker notes** — dual-screen support via tools like PowerPoint, HTML or pympress
+- **Export** — Builtin PDF export, PPTX and HTML via [touying-exporter](https://github.com/touying-typ/touying-exporter)
+- **Correct bookmarks** — proper PDF outline and page numbers out of the box
+
+## Documents & Help
+
+- [Full documentation and references](https://touying-typ.github.io/) (English & Chinese)
+- [Ask DeepWiki](https://deepwiki.com/touying-typ/touying) or [Ask Zread](https://zread.ai/touying-typ/touying) for AI-assisted help
+- [Gallery](https://github.com/touying-typ/touying/wiki) — slides made by the community
+- [Universe](https://typst.app/universe/search/?q=touying) — Diverse touying themes on Typst Universe
+- [Share slides instantly on GitHub](https://gistd.myriad-dreamin.com/touying-typ/touying/blob/main/examples/simple.typ?g-mode=slide) with [gistd](https://github.com/Myriad-Dreamin/gistd) or [export](https://github.com/touying-typ/touying-exporter) slides to PPTX and HTML formats and show presentation [online](https://touying-typ.github.io/touying-template/).
+
+
+## Quick Start
+
+Make sure you have [Typst](https://typst.app/) installed, or use the [Web App](https://typst.app/) / [Tinymist for VS Code](https://marketplace.visualstudio.com/items?itemName=myriad-dreamin.tinymist).
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
+
+
+
+Congratulations on creating your first Touying slide! 🎉
+
+
+## Animations
+
+Touying supports incremental reveal with `#pause` and `#meanwhile`, math equation animations, and integrations with CeTZ and Fletcher:
+
+| Math equations | CeTZ & Fletcher |
+|:---:|:---:|
+|  |  |
+
+For the full feature set — cover mode, callback-style animations, `#uncover`, `#only`, `#alternatives` — see the [documentation](https://touying-typ.github.io/docs/tutorials/dynamic/simple).
+
+
+## Full Example
+
+For a comprehensive example showcasing university theme, theorems, CeTZ/Fletcher animations, speaker notes, and more. You can also use the `#slide[..]` format to access more powerful features provided by Touying.
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+#import "@preview/numbly:0.1.0": numbly
+#import "@preview/theorion:0.6.0": *
+#import cosmos.clouds: *
+#show: show-theorion
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ // align: horizon,
+ // config-common(handout: true),
+ config-common(frozen-counters: (theorem-counter,)), // freeze theorem counter for animation
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= Animation
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+
+== Complex Animation
+
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+
+
+== Callback Style Animation
+
+#slide(
+ repeat: 3,
+ self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+ ],
+)
+
+
+== Math Equation Animation
+
+Equation with `pause`:
+
+$
+ f(x) &= pause x^2 + 2x + 1 \
+ &= pause (x + 1)^2 \
+$
+
+#meanwhile
+
+Here, #pause we have the expression of $f(x)$.
+
+#pause
+
+By factorizing, we can obtain this result.
+
+
+== CeTZ Animation
+
+CeTZ Animation in Touying:
+
+#cetz-canvas({
+ import cetz.draw: *
+
+ rect((0, 0), (5, 5))
+
+ (pause,)
+
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+
+ (pause,)
+
+ line((0, 0), (2.5, 2.5), name: "line")
+})
+
+
+== Fletcher Animation
+
+Fletcher Animation in Touying:
+
+#fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1, 0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0, 0), `reading`, radius: 2em),
+ edge((0, 0), (0, 0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1, 0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2, 0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0, 0), (2, 0), `close()`, "-|>", bend: -40deg),
+)
+
+
+= Theorems
+
+== Prime numbers
+
+#definition[
+ A natural number is called a #highlight[_prime number_] if it is greater
+ than 1 and cannot be written as the product of two smaller natural numbers.
+]
+#example[
+ The numbers $2$, $3$, and $17$ are prime.
+ @cor_largest_prime shows that this list is not exhaustive!
+]
+
+#theorem(title: "Euclid")[
+ There are infinitely many primes.
+]
+#pagebreak(weak: true)
+#proof[
+ Suppose to the contrary that $p_1, p_2, dots, p_n$ is a finite enumeration
+ of all primes. Set $P = p_1 p_2 dots p_n$. Since $P + 1$ is not in our list,
+ it cannot be prime. Thus, some prime factor $p_j$ divides $P + 1$. Since
+ $p_j$ also divides $P$, it must divide the difference $(P + 1) - P = 1$, a
+ contradiction.
+]
+
+#corollary[
+ There is no largest prime number.
+] <cor_largest_prime>
+#corollary[
+ There are infinitely many composite numbers.
+]
+
+#theorem[
+ There are arbitrarily long stretches of composite numbers.
+]
+
+#proof[
+ For any $n > 2$, consider $
+ n! + 2, quad n! + 3, quad ..., quad n! + n
+ $
+]
+
+
+= Others
+
+== Multiple columns
+
+#cols[
+ First column.
+][
+ Second column.
+]
+
+== Multiple columns with equal height blocks
+
+#cols(columns: (1fr, 1fr), gutter: 1em)[
+ #emph-block[
+ First column with equal height: #lorem(10)
+ #lazy-v(1fr)
+ ]
+][
+ #emph-block[
+ Second column with equal height: : #lorem(15)
+ #lazy-v(1fr)
+ ]
+]
+
+
+== Multiple Pages
+
+#lorem(200)
+
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
+```
+
+
+
+
+## Acknowledgements
+
+Thanks to...
+
+- [@andreasKroepelin](https://github.com/andreasKroepelin) for the `polylux` package
+- [@zral0kh](https://github.com/zral0kh) for the waypoint feature and many improvements
+- [@enklht](https://github.com/enklht) for many fixes and improvements
+- [@Enivex](https://github.com/Enivex) for the `metropolis` theme
+- [@drupol](https://github.com/drupol) for the `university` theme
+- [@pride7](https://github.com/pride7) for the `aqua` theme
+- [@Coekjan](https://github.com/Coekjan) and [@QuadnucYard](https://github.com/QuadnucYard) for the `stargazer` theme
+- [@ntjess](https://github.com/ntjess) for contributing to `fit-to-height`, `fit-to-width` and `cover-with-rect`
+
+
+## Poster
+
+
+
+[View Code](https://github.com/touying-typ/touying-poster)
+
+## Star History
+
+<a href="https://star-history.com/#touying-typ/touying&Date">
+ <picture>
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=touying-typ/touying&type=Date&theme=dark" />
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=touying-typ/touying&type=Date" />
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=touying-typ/touying&type=Date" />
+ </picture>
+</a>
\ No newline at end of file
--- /dev/null
+# Changelog
+
+## v0.7.3
+
+### Minor Breaking Changes
+
+- **feat!: always attach `#speaker-note[]` to the previous slide & default `receive-body-for-new-*-slide-fn` to `false`** ([#354](https://github.com/touying-typ/touying/pull/354))
+ - `#speaker-note[]` now always attaches to the **slide above it**, regardless of how that slide was created (explicit slide calls, heading-triggered section slides, or normal content slides). This eliminates the common pitfall where a `#speaker-note[]` placed after a slide would silently create an unwanted empty "ghost" slide.
+ - `receive-body-for-new-section-slide-fn` and its variants are now **defaulted to `false`** (previously `true`).
+
+### Migration Guide
+
+If you relied on content after `= Section` headings being absorbed into the section slide body, explicitly set `receive-body-for-new-section-slide-fn: true` in your `config-common(...)`.
+
+### Features
+
+- feat: `item-by-item-fn` and presets for it ([#347](https://github.com/touying-typ/touying/pull/347))
+- feat: improved `custom-progressive-outline` and new `section-relationship` and some other things ([#345](https://github.com/touying-typ/touying/pull/345))
+- feat: better lazy-layout for mixed layouts ([#355](https://github.com/touying-typ/touying/pull/355))
+- feat: add `cols` as alias of `side-by-side` and export some components `cols`, `lazy-xxx` to outside ([#356](https://github.com/touying-typ/touying/pull/356))
+- theme(metropolis): add outline-slide for metropolis ([#349](https://github.com/touying-typ/touying/pull/349))
+- feat: add warning for empty slide content height detection
+
+### Documentation
+
+- docs: add multiple columns example and improve docs structure
+
+## v0.7.1
+
+### Features
+
+- feat(agents): `breakable` and `clip` options to avoid slide overflow ([#336](https://github.com/touying-typ/touying/pull/336))
+- feat(components): add `lazy-v` (`lazy-h`) and `lazy-layout` for equalizing multi-column (-row) block heights (widths) ([#339](https://github.com/touying-typ/touying/pull/339))
+- feat: additional `contact` and `extra` field in `config-info` ([#342](https://github.com/touying-typ/touying/pull/342))
+- feat: `touying-get-config` function ([#333](https://github.com/touying-typ/touying/pull/333))
+
+### Fixes
+
+- fix: fix `fit-to-height` and `size-to-pt` and allow text reflow ([#332](https://github.com/touying-typ/touying/pull/332))
+- fix: fix waypoint markers ([#341](https://github.com/touying-typ/touying/pull/341))
+
+
+## v0.7.0
+
+### Features
+
+- **major feature:** a named waypoint feature ([#298](https://github.com/touying-typ/touying/pull/298))
+- feat(waypoint): start param and Waypoints in handout-subslides ([#304](https://github.com/touying-typ/touying/pull/304))
+- feat: auto, "h"-here string and inverse function for string subslide-numbers and waypoints ([#301](https://github.com/touying-typ/touying/pull/301))
+- feat: implicitly allow fn-wrapper based animation functions via reducer ([#300](https://github.com/touying-typ/touying/pull/300))
+
+### Fixes
+
+- fix: fix cover-with-rect breaking long lines of text when partially hidden and fallback functions for color/alpha cover ([#328](https://github.com/touying-typ/touying/pull/328))
+- fix: using explicit numbering in display-current-heading when style=auto ([#329](https://github.com/touying-typ/touying/pull/329))
+- fix: fix ghost slides with show rules. Fix proper consistent handling of show rules and defer keyword ([#317](https://github.com/touying-typ/touying/pull/317))
+- fix: alert not delayed ([#316](https://github.com/touying-typ/touying/pull/316))
+- fix: remove redundant nested text call ([#324](https://github.com/touying-typ/touying/pull/324))
+- fix: function alternatives-match takes into account parameter stretch ([#320](https://github.com/touying-typ/touying/pull/320))
+- fix: correctly handle page margin merge/precedence ([#322](https://github.com/touying-typ/touying/pull/322))
+- fix: fix cover spacing issues surrounding lists ([#303](https://github.com/touying-typ/touying/pull/303))
+- fix: correctly parses negative subslide indices (ints, arrays) for handout-subslides ([#307](https://github.com/touying-typ/touying/pull/307))
+- fix: slide function does not update via scoped import ([#310](https://github.com/touying-typ/touying/pull/310))
+
+Thanks for the contributions from [@zral0kh](https://github.com/zral0kh), [@Andrew15-5](https://github.com/Andrew15-5), [@navdeeprana](https://github.com/navdeeprana), and [@Cemoixerestre](https://github.com/Cemoixerestre).
+
+## v0.6.3
+
+A major bugfix release, fixing many long-standing bugs and introducing many practical features.
+
+### Features
+
+- **feat: add `#jump(n, relative: bool)` as unified animation control; redefine `#pause`/`#meanwhile` as sugar**
+- feat: add `#handout-only` for inline content and `<touying:handout>` label for handout-exclusive slides ([#286](https://github.com/touying-typ/touying/pull/286))
+- feat: add `handout-subslides` to control which subslides appear in handout mode ([#288](https://github.com/touying-typ/touying/pull/288))
+- feat: add `#touying-raw` for animated code block reveals ([#283](https://github.com/touying-typ/touying/pull/283))
+- feat: add full-screen speaker notes mode with slide thumbnail (`show-only-notes`) ([#281](https://github.com/touying-typ/touying/pull/281))
+- feat: support arbitrary aspect ratios (e.g. 16-10) across all themes and speaker-note second screen ([#280](https://github.com/touying-typ/touying/pull/280))
+- feat: add `#item-by-item` animation for list, enum, and terms ([#278](https://github.com/touying-typ/touying/pull/278))
+- feat(recall): add subslide parameter to `#touying-recall` ([#285](https://github.com/touying-typ/touying/pull/285))
+- feat: add `default-composer` to config-common for global slide layout configuration ([#284](https://github.com/touying-typ/touying/pull/284))
+- feat: add `cover-fn` parameter to `uncover` for external package integration (e.g. Fletcher) ([#267](https://github.com/touying-typ/touying/pull/267))
+- feat: minislides can be displayed inline ([#228](https://github.com/touying-typ/touying/pull/228))
+- theme: improve appearance of long author lists in university and stargazer theme ([#242](https://github.com/touying-typ/touying/pull/242))
+- theme(simple): make simple-theme respect color configuration for deco-format ([#252](https://github.com/touying-typ/touying/pull/252))
+- theme(aqua,stargazer): add extra parameter to title-slide ([#291](https://github.com/touying-typ/touying/pull/291))
+
+### Fixes
+
+- fix: prevent ghost-slide blank pages from `touying-set-config` anchor regression ([#289](https://github.com/touying-typ/touying/pull/289))
+- fix: styled content on first slide no longer creates extra slides ([#287](https://github.com/touying-typ/touying/pull/287))
+- fix: remove unoutlined headings from navigation
+- fix: fix `#meanwhile` being ignored inside grid cells, boxes, and other containers ([#274](https://github.com/touying-typ/touying/pull/274))
+- fix: fix `config: parameter` silently ignored across all themes ([#273](https://github.com/touying-typ/touying/pull/273))
+- fix: fix slides after `#show`/`#set` rules not rendering subsequent slides ([#268](https://github.com/touying-typ/touying/pull/268))
+- fix: fix title page PDF page label causing pdfpc presenter notes mismatch ([#277](https://github.com/touying-typ/touying/pull/277))
+- fix: fix duplicate label error for labeled footnotes with `#pause` animations ([#275](https://github.com/touying-typ/touying/pull/275))
+- fix: fix `#pause` inside `#speaker-note` body (nested list items) ([#282](https://github.com/touying-typ/touying/pull/282))
+- theme(dewdrop): fix body content under level-1 heading was silently dropped ([#279](https://github.com/touying-typ/touying/pull/279))
+- theme(stargazer): update stargazer theme margins and fix [#259](https://github.com/touying-typ/touying/pull/259)
+
+### Documentation
+
+- **docs(BIG CHANGE): refactor docs website and add references page**
+- docs: reduce README noise, improve first impression ([#297](https://github.com/touying-typ/touying/pull/297))
+- docs: restructure docs + add docs-preview CI for PRs ([#296](https://github.com/touying-typ/touying/pull/296))
+- docs: comprehensive docstring improvements across all source files ([#294](https://github.com/touying-typ/touying/pull/294))
+
+### Miscellaneous
+
+- chore: add `copilot-setup-steps.yml` and improve `copilot-instructions.md` ([#292](https://github.com/touying-typ/touying/pull/292))
+
+### Theme Migration Guide
+
+**For theme developers upgrading to v0.6.3:**
+
+1. **Move `config` to the last position in `utils.merge-dicts`** to allow user overrides:
+ ```typst
+ // Before
+ self = utils.merge-dicts(self, config, config-page(...))
+
+ // After
+ self = utils.merge-dicts(self, config-page(...), config)
+ ```
+
+2. **Replace `paper` with `utils.page-args-from-aspect-ratio`** to support arbitrary aspect ratios:
+ ```typst
+ // Before
+ config-page(paper: "presentation-" + aspect-ratio, ...)
+
+ // After
+ config-page(..utils.page-args-from-aspect-ratio(aspect-ratio), ...)
+ ```
+
+## v0.6.2
+
+### Features
+
+- feat: allow customisation of `components.checkerboard` ([#161](https://github.com/touying-typ/touying/pull/161))
+
+### Fixes
+
+- fix: support ratio and relative margins for full-width headers ([#256](https://github.com/touying-typ/touying/pull/256))
+- fix: fix `magic.bibliography-as-footnote` in Typst 0.14 ([#249](https://github.com/touying-typ/touying/pull/249))
+- fix: theorion package is broken with Typst 0.14.0 ([#237](https://github.com/touying-typ/touying/pull/237))
+- fix: update `components.typ` and pass named arguments to grid ([#207](https://github.com/touying-typ/touying/pull/207))
+- fix: fix `#meanwhile` in cetz ([#205](https://github.com/touying-typ/touying/pull/205))
+- fix: documentation contains unclosed raw text error ([#187](https://github.com/touying-typ/touying/pull/187))
+- fix: use correct circle symbol ([#171](https://github.com/touying-typ/touying/pull/171))
+- fix: use regex to override colors of equations ([#167](https://github.com/touying-typ/touying/pull/167))
+- fix: `show-hide-set-list-marker-none` with full enum ([#157](https://github.com/touying-typ/touying/pull/157))
+- fix: remove dump and label-it function for better cache
+
+### Miscellaneous
+
+- docs: update README, bump versions of deps, and fix comment docs
+- ci: add more tests, bump versions of `tytanic`, and update typstyle workflow ([#221](https://github.com/touying-typ/touying/pull/221), [#261](https://github.com/touying-typ/touying/pull/261))
+
+
+## v0.6.1
+
+Added support for the [theorion](https://github.com/OrangeX4/typst-theorion) package, and used it as the default math theorem environment.
+
+## v0.6.0
+
+It's not a big update, but it's the first touying release since typst 0.13 was released.
+
+### Features
+
+- feat: add auto style for display-current-heading.
+ - For users, you can use `show heading: set text(blue)` to change color for heading in some themes like `dewdrop`.
+ - For theme creator, you can use syntax like `utils.display-current-heading(level: 1, style: auto)` to achieve the same result.
+- feat: apply config-info information to `set document`.
+- feat: set `stretch: false` by default for `alternatives` functions. This is **a minor breaking change**, but I think it would be more intuitive: no auto empty space.
+
+### Fixes
+
+- fix: fix error with uncover using semi-transparent-cover
+- fix: fix type string comparison https://github.com/touying-typ/touying/pull/153
+- fix: fix horizontal-line bug in typst 0.13.0
+- refactor: fix display-current-short-heading
+
+
+## v0.5.4 & v0.5.5
+
+### Features
+
+- docs: improve param documentation and we have better hints for tinymist https://github.com/touying-typ/touying/pull/98
+- feat: fake frozon states support for `heading` https://github.com/touying-typ/touying/pull/124
+- feat: add alpha-changing-cover and color-changing-cover https://github.com/touying-typ/touying/pull/129
+- feat: add effect function https://github.com/touying-typ/touying/issues/111
+ - Example: `#effect(text.with(fill: red), "2-")[Something]` will display `[Something]` if the current slide is 2 or later.
+- feat: add argument `config: (..)` for `xxx-slide` functions
+- feat: add `align` argument for university theme
+
+### Fixes
+
+- fix: also hide enum numbers with show-hide-set-list-marker-none https://github.com/touying-typ/touying/pull/114
+- fix: fixed progress bar not to break apart when global figure gutter is set nonzero https://github.com/touying-typ/touying/pull/120
+- fix: fixed frozen-counters bug with multiple #pause commands https://github.com/touying-typ/touying/pull/124
+- fix: fixed incorrect page num when draft is true https://github.com/touying-typ/touying/pull/125
+- fix: fix behaviors of fit-to-height and fit-to-width partially https://github.com/touying-typ/touying/pull/131
+- fix: duplicated footnotes in headings https://github.com/touying-typ/touying/pull/132
+- fix: do not hardcode page sizes https://github.com/touying-typ/touying/pull/134
+- fix: add default numbering for page https://github.com/touying-typ/touying/issues/100
+- refactor: move show-strong-with-alert to per-slide level https://github.com/touying-typ/touying/issues/123
+- refactor: remove unnecessary `config-page(fill: ...)`
+- theme(metropolis): fix color of title page and fix https://github.com/touying-typ/touying/issues/103
+- theme(metropolis): fixed metropolis slide's header to return content if title is specified https://github.com/touying-typ/touying/pull/126
+- theme(metropolis): respect colors dict in metropolis theme https://github.com/touying-typ/touying/pull/133
+- fix: fix bug of `#effect` function
+
+Thanks for the contributions from [@enklht](https://github.com/enklht).
+
+
+## v0.5.3
+
+### Features
+
+- feat: add `stretch` parameter for `#alternatives[]` function class. This allows us to handle cases where the internal element is a context expression.
+- feat: add `config-common(align-enum-marker-with-baseline: true)` for aligning the enum marker with the baseline.
+- feat: add `linebreaks` option to `components.mini-slides`. https://github.com/touying-typ/touying/pull/96
+- feat: add `<touying:skip>` label to skip a new-section-slide.
+- feat: add `config-common(show-hide-set-list-marker-none: true)` to make the markers of `list` and `enum` invisible after `#pause`.
+- feat: add `config-common(bibliography-as-footnote: bibliography(title: none, "ref.bib"))` to display the bibliography in footnotes.
+- refactor: add `config-common(show-strong-with-alert: true)` configuration to display strong text with an alert. (small breaking change for some themes)
+- refactor: refactor `display-current-heading` for preserving heading style in title and subtitle. https://github.com/touying-typ/touying/issues/71
+- refactor: make `new-section-slide-fn` function class can receive `body` parameter. We can use `receive-body-for-new-section-slide-fn` to control it. **(Breaking change)**
+ - For example, you can add `#speaker-note[]` for a new section slide, like `= Section Title \ #speaker-note[]`.
+ - If you don't want to append content to the body of the new section slide, you can use `---` after the section title.
+
+### Fixes
+
+- fix outdated documentation.
+- fix bug of `enable-frozen-states-and-counters` in handout mode.
+- fix unusable `square()` function. https://github.com/touying-typ/touying/issues/73
+- fix hidden footer for `show-notes-on-second-screen: bottom`. https://github.com/touying-typ/touying/issues/89
+- fix metadata element in table cells. https://github.com/touying-typ/touying/issues/77 https://github.com/touying-typ/touying/issues/95
+- fix `auto-offset-for-heading` to `false` by default.
+- fix uncover/only hides more content than it should. https://github.com/touying-typ/touying/issues/85
+- theme(simple): fix wrong title and subtitle. https://github.com/touying-typ/touying/issues/70
+
+
+## v0.5.1 & v0.5.2
+
+- Fix somg bugs.
+
+
+## v0.5.0
+
+This is a significant disruptive version update. Touying has removed many mistakes that resulted from incorrect decisions. We have redesigned numerous features. The goal of this version is to make Touying more user-friendly, more flexible, and more powerful.
+
+**Major changes include:**
+
+- Avoiding closures and OOP syntax, which makes Touying's configuration simpler and allows for the use of document comments to provide more auto-completion information for the slide function.
+ - The existing `#let slide(self: none, ..args) = { .. }` is now `#let slide(..args) = touying-slide-wrapper(self => { .. })`, where `self` is automatically injected.
+ - We can use `config-xxx` syntax to configure Touying, for example, `#show: university-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))`.
+- The `touying-slide` function no longer includes parameters like `section`, `subsection`, and `title`. These will be automatically inserted into the slide as invisible level 1, 2, or 3 headings via `self.headings` (controlled by the `slide-level` configuration).
+ - We can leverage the powerful headings provided by Typst to support numbering, outlines, and bookmarks.
+ - Headings within the `#slide[= XXX]` function will be adjusted to level `slide-level + 1` using the `offset` parameter.
+ - We can use labels on headings to control many aspects, such as supporting the `<touying:hidden>` and other special labels, implementing short headings, or recalling a slide with `#touying-recall()`.
+- Touying now supports the normal use of `set` and `show` rules at any position, without requiring them to be in specific locations.
+
+A simple usage example is shown below, and more examples can be found in the `examples` directory:
+
+```typst
+#import "@preview/touying:0.5.0": *
+#import themes.university: *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: "1.1")
+
+#title-slide()
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+```
+
+**Theme Migration Guide:**
+
+For detailed changes to specific themes, you can refer to the `themes` directory. Generally, if you want to migrate an existing theme, you should:
+
+1. Rename the `register` function to `xxx-theme` and remove the `self` parameter.
+2. Add a `show: touying-slides.with(..)` configuration.
+ - Change `self.methods.colors` to `config-colors(primary: rgb("#xxxxxx"))`.
+ - Change `self.page-args` to `config-page()`.
+ - Change `self.methods.slide = slide` to `config-methods(slide: slide)`.
+ - Change `self.methods.new-section-slide = new-section-slide` to `config-methods(new-section-slide: new-section-slide)`.
+ - Change private theme variables like `self.xxx-footer` to `config-store(footer: [..])`, which you can access through `self.store.footer`.
+ - Move the configuration of headers and footers into the `slide` function rather than in the `xxx-theme` function.
+ - You can directly use `set` or `show` rules in `xxx-theme` or configure them through `config-methods(init: (self: none, body) => { .. })` to fully utilize the `self` parameter.
+3. For `states.current-section-with-numbering`, you can use `utils.display-current-heading(level: 1)` instead.
+ - If you only need the previous heading regardless of whether it is a section or a subsection, use `self => utils.display-current-heading(depth: self.slide-level)`.
+4. The `alert` function can be replaced with `config-methods(alert: utils.alert-with-primary-color)`.
+5. The `touying-outline()` function is no longer needed; you can use `components.adaptive-columns(outline())` instead. Consider using `components.progressive-outline()` or `components.custom-progressive-outline()`.
+6. Replace `states.slide-counter.display() + " / " + states.last-slide-number` with `context utils.slide-counter.display() + " / " + utils.last-slide-number`. That is, we no longer use `states` but `utils`.
+7. Remove the `slides` function; we no longer need this function. Instead of implicitly injecting `title-slide()`, explicitly use `#title-slide()`. If necessary, consider adding it in the `xxx-theme` function.
+8. Change `#let slide(self: none, ..args) = { .. }` to `#let slide(..args) = touying-slide-wrapper(self => { .. })`, where `self` is automatically injected.
+ - Change specific parameter configurations to `self = utils.merge-dicts(self, config-page(fill: self.colors.neutral-lightest))`.
+ - Remove `self = utils.empty-page(self)` and use `config-common(freeze-slide-counter: true)` and `config-page(margin: 0em)` instead.
+ - Change `(self.methods.touying-slide)()` to `touying-slide()`.
+9. You can insert visible headings into slides by configuring `config-common(subslide-preamble: self => text(1.2em, weight: "bold", utils.display-current-heading(depth: self.slide-level)))`.
+10. Finally, don't forget to add document comments to your functions so your users can get better auto-completion hints, especially when using the Tinymist plugin.
+
+**Other Changes:**
+
+- theme(stargazer): new stargazer theme modified from [Coekjan/touying-buaa](https://github.com/Coekjan/touying-buaa).
+- feat: implemented fake frozen states support, allowing you to use numbering and `#pause` normally. This behavior can be controlled with `enable-frozen-states-and-counters`, `frozen-states`, and `frozen-counters` in `config-common()`.
+- feat: implemented `label-only-on-last-subslide` functionality to prevent non-unique label warnings when working with `@equation` and `@figure` in conjunction with `#pause` animations.
+- feat: added the `touying-recall(<label>)` function to replay a specific slide.
+- feat: implemented `nontight-list-enum-and-terms`, which defaults to `true` and forces `list`, `enum`, and `terms` to have their `tight` parameter set to `false`. You can control spacing size with `#set list(spacing: 1em)`.
+- feat: replaced `list` with `terms` implementation to achieve `align-list-marker-with-baseline`, which is off by default.
+- feat: implemented `scale-list-items`, scaling list items by a factor, e.g., `scale-list-items: 0.8` scales list items by 0.8.
+- feat: supported direct use of `#pause` and `#meanwhile` in math expressions, such as `$x + pause y$`.
+- feat: provided `#pause` and `#meanwhile` support for most layout functions, such as `grid` and `table`.
+- feat: added `#show: appendix` support, essentially equivalent to `#show: touying-set-config.with((appendix: true))`.
+- feat: Introduced special labels `<touying:hidden>`, `<touying:unnumbered>`, `<touying:unoutlined>`, `<touying:unbookmarked>` to simplify control over heading behavior.
+- feat: added basic `utils.short-heading` support to display short headings using labels, such as displaying `<sec:my-section>` as "My Section".
+- feat: added `#components.adaptive-columns()` to achieve adaptive columns that span a page, typically used with the `outline()` function.
+- feat: added `#show: magic.bibliography-as-footnote.with(bibliography("ref.bib"))` to display the bibliography in footnotes.
+- feat: added components like `custom-progressive-outline`, `mini-slides`.
+- feat: removed `touying-outline()`, which can be directly replaced with `outline()`.
+- fix: replaced potentially incompatible code, such as `type(s) == "string"` and `locate(loc => { .. })`.
+- fix: Fixed some bugs.
+
+
+## v0.4.2
+
+- theme(metropolis): decoupled text color with `neutral-dark` (Breaking change)
+- feat: add mark-style uncover, only and alternatives
+- feat: add warning for styled block for slides
+- feat: add warning for touying-temporary-mark
+- feat: add markup-text for speaker-note
+- fix: fix bug of slides
+
+
+## v0.4.1
+
+### Features
+
+- feat: support builtin outline and bookmark
+- feat: support speaker note for dual-screen
+- feat: add touying-mitex function
+
+### Fixes
+
+- fix: add outline-slide for dewdrop theme
+- fix: fix regression of default value "auto" for repeat
+
+### Miscellaneous Improvements
+
+- feat: add list support for `touying-outline` function
+- feat: add auto-reset-footnote
+- feat: add `freeze-in-empty-page` for better page counter
+- feat: add `..args` for register method to capture unused arguments
+
+
+## v0.4.0
+
+### Features
+
+- **feat:** support `#footnote[]` for all themes.
+- **feat:** access subslide and repeat in footer and header by `self => self.subslide`.
+- **feat:** support numbered theorem environments by [ctheorems](https://typst.app/universe/package/ctheorems).
+- **feat:** support numbering for sections and subsections.
+
+### Fixes
+
+- **fix:** make nested includes work correctly.
+- **fix:** disable multi-page slides from creating the same section multiple times.
+
+## Breaking changes
+
+- **refactor:** remove `self.padding` and add `self.full-header` `self.full-footer` config.
+
+
+## v0.3.3
+
+- **template:** move template to `touying-aqua` package, make Touying searchable in [Typst Universe Packages](https://typst.app/universe/search?kind=packages)
+- **themes:** fix bugs in university and dewdrop theme
+- **feat:** make set-show rule work without `setting` parameter
+- **feat:** make `composer` parameter more simpler
+- **feat:** add `empty-slide` function
+
+## v0.3.2
+
+- **fix critical bug:** fix `is-sequence` function, make `grid` and `table` work correctly in touying
+- **theme:** add aqua theme, thanks for pride7
+- **theme:** make university theme more configurable
+- **refactor:** don't export variable `s` by default anymore, it will be extracted by `register` function (**Breaking Change**)
+- **meta:** add `categories` and `template` config to `typst.toml` for Typst 0.11
+
+
+## v0.3.1
+
+- fix some typos
+- fix slide-level bug
+- fix bug of pdfpc label
+
+
+## v0.3.0
+
+### Features
+
+- better show-slides mode.
+- support align and pad.
+
+### Documentation
+
+- Add more detailed documentation.
+
+### Refactor
+
+- simplify theme.
+
+### Fix
+
+- fix many bugs.
+
+## v0.2.1
+
+### Features
+
+- **Touying-reducer**: support cetz and fletcher animation
+- **university theme**: add university theme
+
+### Fix
+
+- fix footer progress in metropolis theme
+- fix some bugs in simple and dewdrop themes
+- fix bug that outline does not display more than 4 sections
+
+
+## v0.2.0
+
+- **Object-oriented programming:** Singleton `s`, binding methods `utils.methods(s)` and `(self: obj, ..) => {..}` methods.
+- **Page arguments management:** Instead of using `#set page(..)`, you should use `self.page-args` to retrieve or set page parameters, thereby avoiding unnecessary creation of new pages.
+- **`#pause` for sequence content:** You can use #pause at the outermost level of a slide, including inline and list.
+- **`#pause` for layout functions:** You can use the `composer` parameter to add yourself layout function like `utils.side-by-side`, and simply use multiple pos parameters like `#slide[..][..]`.
+- **`#meanwhile` for synchronous display:** Provide a `#meanwhile` for resetting subslides counter.
+- **`#pause` and `#meanwhile` for math equation:** Provide a `#touying-equation("x + y pause + z")` for math equation animations.
+- **Slides:** Create simple slides using standard headings.
+- **Callback-style `uncover`, `only` and `alternatives`:** Based on the concise syntax provided by Polylux, allow precise control of the timing for displaying content.
+ - You should manually control the number of subslides using the `repeat` parameter.
+- **Transparent cover:** Enable transparent cover using oop syntax like `#let s = (s.methods.enable-transparent-cover)(self: s)`.
+- **Handout mode:** enable handout mode by `#let s = (s.methods.enable-handout-mode)(self: s)`.
+- **Fit-to-width and fit-to-height:** Fit-to-width for title in header and fit-to-height for image.
+ - `utils.fit-to-width(grow: true, shrink: true, width, body)`
+ - `utils.fit-to-height(width: none, prescale-width: none, grow: true, shrink: true, height, body)`
+- **Slides counter:** `states.slide-counter.display() + " / " + states.last-slide-number` and `states.touying-progress(ratio => ..)`.
+- **Appendix:** Freeze the `last-slide-number` to prevent the slide number from increasing further.
+- **Sections:** Touying's built-in section support can be used to display the current section title and show progress.
+ - `section` and `subsection` parameter in `#slide` to register a new section or subsection.
+ - `states.current-section-title` to get the current section.
+ - `states.touying-outline` or `s.methods.touying-outline` to display a outline of sections.
+ - `states.touying-final-sections(sections => ..)` for custom outline display.
+ - `states.touying-progress-with-sections((current-sections: .., final-sections: .., current-slide-number: .., last-slide-number: ..) => ..)` for powerful progress display.
+- **Navigation bar**: Navigation bar like [here](https://github.com/zbowang/BeamerTheme) by `states.touying-progress-with-sections(..)`, in `dewdrop` theme.
+- **Pdfpc:** pdfpc support and export `.pdfpc` file without external tool by `typst query` command simply.
--- /dev/null
+# Touying Docs Authoring Guide
+
+This directory contains the source documentation for Touying.
+The docs are written in Markdown and are consumed by the website repository.
+
+## Scope
+
+- `docs/en/`: English source docs.
+- `docs/zh/`: Chinese source docs.
+- `docs/README.md`: This guide for contributors.
+
+## Key Writing Conventions
+
+### 1) Use `example` fences for executable examples
+
+Use fenced code blocks with the `example` info string when the snippet is intended to be rendered and validated as a runnable example by docs tooling.
+
+Example:
+
+````markdown
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+== First Slide
+Hello, Touying!
+```
+````
+
+Use regular fences like `typst`, `md`, or `bash` for non-executable snippets.
+
+### 2) `>>>` lines are setup-only prelude lines
+
+Inside an `example` block, lines prefixed with `>>> ` are used as prelude/setup content.
+This is typically used to inject common Touying bootstrap code (imports and theme setup) for snippet execution without repeating full boilerplate in the visible demo body.
+
+Typical prelude:
+
+```typst
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+```
+
+Use this pattern when the snippet body starts directly from feature usage such as `#slide[...]`, `#pause`, `#meanwhile`, `#only`, or `#uncover`.
+
+### 3) `<<<` lines are display-oriented helper lines
+
+Inside an `example` block, `<<< ` is used for lines that should be treated differently from normal runnable body lines by docs tooling.
+In this docs set, it is mainly used in multi-file tutorial snippets (for example `main.typ` portions) to keep the instructional text accurate while preserving example processing behavior.
+
+Keep `<<<` usage minimal and only for cases that need this distinction.
+
+## Localization and Publishing Paths
+
+Content from this repository is published in `touying-typ/touying-typ.github.io`.
+
+- English docs (`docs/en/**`) are placed under `docs/**`.
+- Chinese docs (`docs/zh/**`) are placed under `i18n/zh/docusaurus-plugin-content-docs/current/**`.
+
+When updating docs, keep `en` and `zh` aligned in structure and topic coverage whenever possible.
+
+## Contributor Checklist
+
+- Use `example` fences for runnable demos.
+- Add `>>>` prelude lines when a snippet needs Touying bootstrap setup.
+- Keep `<<<` only where multi-file or special display/execution behavior is required.
+- Preserve heading structure and frontmatter consistency across `en` and `zh`.
+- Verify links and package versions in examples.
--- /dev/null
+---
+sidebar_position: 8
+---
+
+# Changelog
+
+## v0.7.3
+
+### Minor Breaking Changes
+
+- **feat!: always attach `#speaker-note[]` to the previous slide & default `receive-body-for-new-*-slide-fn` to `false`** ([#354](https://github.com/touying-typ/touying/pull/354))
+ - `#speaker-note[]` now always attaches to the **slide above it**, regardless of how that slide was created (explicit slide calls, heading-triggered section slides, or normal content slides). This eliminates the common pitfall where a `#speaker-note[]` placed after a slide would silently create an unwanted empty "ghost" slide.
+ - `receive-body-for-new-section-slide-fn` and its variants are now **defaulted to `false`** (previously `true`).
+
+### Migration Guide
+
+If you relied on content after `= Section` headings being absorbed into the section slide body, explicitly set `receive-body-for-new-section-slide-fn: true` in your `config-common(...)`.
+
+### Features
+
+- feat: `item-by-item-fn` and presets for it ([#347](https://github.com/touying-typ/touying/pull/347))
+- feat: improved `custom-progressive-outline` and new `section-relationship` and some other things ([#345](https://github.com/touying-typ/touying/pull/345))
+- feat: better lazy-layout for mixed layouts ([#355](https://github.com/touying-typ/touying/pull/355))
+- feat: add `cols` as alias of `side-by-side` and export some components `cols`, `lazy-xxx` to outside ([#356](https://github.com/touying-typ/touying/pull/356))
+- theme(metropolis): add outline-slide for metropolis ([#349](https://github.com/touying-typ/touying/pull/349))
+- feat: add warning for empty slide content height detection
+
+### Documentation
+
+- docs: add multiple columns example and improve docs structure
+
+## v0.7.1
+
+### Features
+
+- feat(agents): `breakable` and `clip` options to avoid slide overflow ([#336](https://github.com/touying-typ/touying/pull/336))
+- feat(components): add `lazy-v` (`lazy-h`) and `lazy-layout` for equalizing multi-column (-row) block heights (widths) ([#339](https://github.com/touying-typ/touying/pull/339))
+- feat: additional `contact` and `extra` field in `config-info` ([#342](https://github.com/touying-typ/touying/pull/342))
+- feat: `touying-get-config` function ([#333](https://github.com/touying-typ/touying/pull/333))
+
+### Fixes
+
+- fix: fix `fit-to-height` and `size-to-pt` and allow text reflow ([#332](https://github.com/touying-typ/touying/pull/332))
+- fix: fix waypoint markers ([#341](https://github.com/touying-typ/touying/pull/341))
+
+
+## v0.7.0
+
+### Features
+
+- **major feature:** a named waypoint feature ([#298](https://github.com/touying-typ/touying/pull/298))
+- feat(waypoint): start param and Waypoints in handout-subslides ([#304](https://github.com/touying-typ/touying/pull/304))
+- feat: auto, "h"-here string and inverse function for string subslide-numbers and waypoints ([#301](https://github.com/touying-typ/touying/pull/301))
+- feat: implicitly allow fn-wrapper based animation functions via reducer ([#300](https://github.com/touying-typ/touying/pull/300))
+
+### Fixes
+
+- fix: fix cover-with-rect breaking long lines of text when partially hidden and fallback functions for color/alpha cover ([#328](https://github.com/touying-typ/touying/pull/328))
+- fix: using explicit numbering in display-current-heading when style=auto ([#329](https://github.com/touying-typ/touying/pull/329))
+- fix: fix ghost slides with show rules. Fix proper consistent handling of show rules and defer keyword ([#317](https://github.com/touying-typ/touying/pull/317))
+- fix: alert not delayed ([#316](https://github.com/touying-typ/touying/pull/316))
+- fix: remove redundant nested text call ([#324](https://github.com/touying-typ/touying/pull/324))
+- fix: function alternatives-match takes into account parameter stretch ([#320](https://github.com/touying-typ/touying/pull/320))
+- fix: correctly handle page margin merge/precedence ([#322](https://github.com/touying-typ/touying/pull/322))
+- fix: fix cover spacing issues surrounding lists ([#303](https://github.com/touying-typ/touying/pull/303))
+- fix: correctly parses negative subslide indices (ints, arrays) for handout-subslides ([#307](https://github.com/touying-typ/touying/pull/307))
+- fix: slide function does not update via scoped import ([#310](https://github.com/touying-typ/touying/pull/310))
+
+Thanks for the contributions from [@zral0kh](https://github.com/zral0kh), [@Andrew15-5](https://github.com/Andrew15-5), [@navdeeprana](https://github.com/navdeeprana), and [@Cemoixerestre](https://github.com/Cemoixerestre).
+
+
+## v0.6.3
+
+A major bugfix release, fixing many long-standing bugs and introducing many practical features.
+
+### Features
+
+- **feat: add `#jump(n, relative: bool)` as unified animation control; redefine `#pause`/`#meanwhile` as sugar**
+- feat: add `#handout-only` for inline content and `<touying:handout>` label for handout-exclusive slides ([#286](https://github.com/touying-typ/touying/pull/286))
+- feat: add `handout-subslides` to control which subslides appear in handout mode ([#288](https://github.com/touying-typ/touying/pull/288))
+- feat: add `#touying-raw` for animated code block reveals ([#283](https://github.com/touying-typ/touying/pull/283))
+- feat: add full-screen speaker notes mode with slide thumbnail (`show-only-notes`) ([#281](https://github.com/touying-typ/touying/pull/281))
+- feat: support arbitrary aspect ratios (e.g. 16-10) across all themes and speaker-note second screen ([#280](https://github.com/touying-typ/touying/pull/280))
+- feat: add `#item-by-item` animation for list, enum, and terms ([#278](https://github.com/touying-typ/touying/pull/278))
+- feat(recall): add subslide parameter to `#touying-recall` ([#285](https://github.com/touying-typ/touying/pull/285))
+- feat: add `default-composer` to config-common for global slide layout configuration ([#284](https://github.com/touying-typ/touying/pull/284))
+- feat: add `cover-fn` parameter to `uncover` for external package integration (e.g. Fletcher) ([#267](https://github.com/touying-typ/touying/pull/267))
+- feat: minislides can be displayed inline ([#228](https://github.com/touying-typ/touying/pull/228))
+- theme: improve appearance of long author lists in university and stargazer theme ([#242](https://github.com/touying-typ/touying/pull/242))
+- theme(simple): make simple-theme respect color configuration for deco-format ([#252](https://github.com/touying-typ/touying/pull/252))
+- theme(aqua,stargazer): add extra parameter to title-slide ([#291](https://github.com/touying-typ/touying/pull/291))
+
+### Fixes
+
+- fix: prevent ghost-slide blank pages from `touying-set-config` anchor regression ([#289](https://github.com/touying-typ/touying/pull/289))
+- fix: styled content on first slide no longer creates extra slides ([#287](https://github.com/touying-typ/touying/pull/287))
+- fix: remove unoutlined headings from navigation
+- fix: fix `#meanwhile` being ignored inside grid cells, boxes, and other containers ([#274](https://github.com/touying-typ/touying/pull/274))
+- fix: fix `config: parameter` silently ignored across all themes ([#273](https://github.com/touying-typ/touying/pull/273))
+- fix: fix slides after `#show`/`#set` rules not rendering subsequent slides ([#268](https://github.com/touying-typ/touying/pull/268))
+- fix: fix title page PDF page label causing pdfpc presenter notes mismatch ([#277](https://github.com/touying-typ/touying/pull/277))
+- fix: fix duplicate label error for labeled footnotes with `#pause` animations ([#275](https://github.com/touying-typ/touying/pull/275))
+- fix: fix `#pause` inside `#speaker-note` body (nested list items) ([#282](https://github.com/touying-typ/touying/pull/282))
+- theme(dewdrop): fix body content under level-1 heading was silently dropped ([#279](https://github.com/touying-typ/touying/pull/279))
+- theme(stargazer): update stargazer theme margins and fix [#259](https://github.com/touying-typ/touying/pull/259)
+
+### Documentation
+
+- **docs(BIG CHANGE): refactor docs website and add references page**
+- docs: reduce README noise, improve first impression ([#297](https://github.com/touying-typ/touying/pull/297))
+- docs: restructure docs + add docs-preview CI for PRs ([#296](https://github.com/touying-typ/touying/pull/296))
+- docs: comprehensive docstring improvements across all source files ([#294](https://github.com/touying-typ/touying/pull/294))
+
+### Theme Migration Guide
+
+**For theme developers upgrading to v0.6.3:**
+
+1. **Move `config` to the last position in `utils.merge-dicts`** to allow user overrides:
+ ```typst
+ // Before
+ self = utils.merge-dicts(self, config, config-page(...))
+
+ // After
+ self = utils.merge-dicts(self, config-page(...), config)
+ ```
+
+2. **Replace `paper` with `utils.page-args-from-aspect-ratio`** to support arbitrary aspect ratios:
+ ```typst
+ // Before
+ config-page(paper: "presentation-" + aspect-ratio, ...)
+
+ // After
+ config-page(..utils.page-args-from-aspect-ratio(aspect-ratio), ...)
+ ```
+
+
+## v0.6.2
+
+### Features
+
+- feat: allow customisation of `components.checkerboard` (#161)
+
+### Fixes
+
+- fix: support ratio and relative margins for full-width headers (#256)
+- fix: fix `magic.bibliography-as-footnote` in Typst 0.14 (#249)
+- fix: theorion package is broken with Typst 0.14.0 (#237)
+- fix: update `components.typ` and pass named arguments to grid (#207)
+- fix: fix `#meanwhile` in cetz (#205)
+- fix: documentation contains unclosed raw text error (#187)
+- fix: use correct circle symbol (#171)
+- fix: use regex to override colors of equations (#167)
+- fix: `show-hide-set-list-marker-none` with full enum (#157)
+- fix: remove dump and label-it function for better cache
+
+### Miscellaneous
+
+- docs: update README, bump versions of deps, and fix comment docs
+- ci: add more tests, bump versions of `tytanic`, and update typstyle workflow (#221, #261)
+
+
+## v0.6.1
+
+Added support for the [theorion](https://github.com/OrangeX4/typst-theorion) package, and used it as the default math theorem environment.
+
+## v0.6.0
+
+It's not a big update, but it's the first touying release since typst 0.13 was released.
+
+### Features
+
+- feat: add auto style for display-current-heading.
+ - For users, you can use `show heading: set text(blue)` to change color for heading in some themes like `dewdrop`.
+ - For theme creator, you can use syntax like `utils.display-current-heading(level: 1, style: auto)` to achieve the same result.
+- feat: apply config-info information to `set document`.
+- feat: set `stretch: false` by default for `alternatives` functions. This is **a minor breaking change**, but I think it would be more intuitive: no auto empty space.
+
+### Fixes
+
+- fix: fix error with uncover using semi-transparent-cover
+- fix: fix type string comparison https://github.com/touying-typ/touying/pull/153
+- fix: fix horizontal-line bug in typst 0.13.0
+- refactor: fix display-current-short-heading
+
+
+## v0.5.4 & v0.5.5
+
+### Features
+
+- docs: improve param documentation and we have better hints for tinymist https://github.com/touying-typ/touying/pull/98
+- feat: fake frozon states support for `heading` https://github.com/touying-typ/touying/pull/124
+- feat: add alpha-changing-cover and color-changing-cover https://github.com/touying-typ/touying/pull/129
+- feat: add effect function https://github.com/touying-typ/touying/issues/111
+ - Example: `#effect(text.with(fill: red), "2-")[Something]` will display `[Something]` if the current slide is 2 or later.
+- feat: add argument `config: (..)` for `xxx-slide` functions
+- feat: add `align` argument for university theme
+
+### Fixes
+
+- fix: also hide enum numbers with show-hide-set-list-marker-none https://github.com/touying-typ/touying/pull/114
+- fix: fixed progress bar not to break apart when global figure gutter is set nonzero https://github.com/touying-typ/touying/pull/120
+- fix: fixed frozen-counters bug with multiple #pause commands https://github.com/touying-typ/touying/pull/124
+- fix: fixed incorrect page num when draft is true https://github.com/touying-typ/touying/pull/125
+- fix: fix behaviors of fit-to-height and fit-to-width partially https://github.com/touying-typ/touying/pull/131
+- fix: duplicated footnotes in headings https://github.com/touying-typ/touying/pull/132
+- fix: do not hardcode page sizes https://github.com/touying-typ/touying/pull/134
+- fix: add default numbering for page https://github.com/touying-typ/touying/issues/100
+- refactor: move show-strong-with-alert to per-slide level https://github.com/touying-typ/touying/issues/123
+- refactor: remove unnecessary `config-page(fill: ...)`
+- theme(metropolis): fix color of title page and fix https://github.com/touying-typ/touying/issues/103
+- theme(metropolis): fixed metropolis slide's header to return content if title is specified https://github.com/touying-typ/touying/pull/126
+- theme(metropolis): respect colors dict in metropolis theme https://github.com/touying-typ/touying/pull/133
+
+Thanks for the contributions from [@enklht](https://github.com/enklht).
+
+
+## v0.5.3
+
+### Features
+
+- feat: add `stretch` parameter for `#alternatives[]` function class. This allows us to handle cases where the internal element is a context expression.
+- feat: add `config-common(align-enum-marker-with-baseline: true)` for aligning the enum marker with the baseline.
+- feat: add `linebreaks` option to `components.mini-slides`. https://github.com/touying-typ/touying/pull/96
+- feat: add `<touying:skip>` label to skip a new-section-slide.
+- feat: add `config-common(show-hide-set-list-marker-none: true)` to make the markers of `list` and `enum` invisible after `#pause`.
+- feat: add `config-common(bibliography-as-footnote: bibliography(title: none, "ref.bib"))` to display the bibliography in footnotes.
+- refactor: add `config-common(show-strong-with-alert: true)` configuration to display strong text with an alert. (small breaking change for some themes)
+- refactor: refactor `display-current-heading` for preserving heading style in title and subtitle. https://github.com/touying-typ/touying/issues/71
+- refactor: make `new-section-slide-fn` function class can receive `body` parameter. We can use `receive-body-for-new-section-slide-fn` to control it. **(Breaking change)**
+ - For example, you can add `#speaker-note[]` for a new section slide, like `= Section Title \ #speaker-note[]`.
+ - If you don't want to append content to the body of the new section slide, you can use `---` after the section title.
+
+### Fixes
+
+- fix outdated documentation.
+- fix bug of `enable-frozen-states-and-counters` in handout mode.
+- fix unusable `square()` function. https://github.com/touying-typ/touying/issues/73
+- fix hidden footer for `show-notes-on-second-screen: bottom`. https://github.com/touying-typ/touying/issues/89
+- fix metadata element in table cells. https://github.com/touying-typ/touying/issues/77 https://github.com/touying-typ/touying/issues/95
+- fix `auto-offset-for-heading` to `false` by default.
+- fix uncover/only hides more content than it should. https://github.com/touying-typ/touying/issues/85
+- theme(simple): fix wrong title and subtitle. https://github.com/touying-typ/touying/issues/70
+
+
+## v0.5.1 & v0.5.2
+
+- Fix some bugs.
+
+
+## v0.5.0
+
+This is a significant disruptive version update. Touying has removed many mistakes that resulted from incorrect decisions. We have redesigned numerous features. The goal of this version is to make Touying more user-friendly, more flexible, and more powerful.
+
+**Major changes include:**
+
+- Avoiding closures and OOP syntax, which makes Touying's configuration simpler and allows for the use of document comments to provide more auto-completion information for the slide function.
+ - The existing `#let slide(self: none, ..args) = { .. }` is now `#let slide(..args) = touying-slide-wrapper(self => { .. })`, where `self` is automatically injected.
+ - We can use `config-xxx` syntax to configure Touying, for example, `#show: university-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))`.
+- The `touying-slide` function no longer includes parameters like `section`, `subsection`, and `title`. These will be automatically inserted into the slide as invisible level 1, 2, or 3 headings via `self.headings` (controlled by the `slide-level` configuration).
+ - We can leverage the powerful headings provided by Typst to support numbering, outlines, and bookmarks.
+ - Headings within the `#slide[= XXX]` function will be adjusted to level `slide-level + 1` using the `offset` parameter.
+ - We can use labels on headings to control many aspects, such as supporting the `<touying:hidden>` and other special labels, implementing short headings, or recalling a slide with `#touying-recall()`.
+- Touying now supports the normal use of `set` and `show` rules at any position, without requiring them to be in specific locations.
+
+A simple usage example is shown below, and more examples can be found in the `examples` directory:
+
+```typst
+#import "@preview/touying:0.6.3": *
+#import themes.university: *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: "1.1")
+
+#title-slide()
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+```
+
+**Theme Migration Guide:**
+
+For detailed changes to specific themes, you can refer to the `themes` directory. Generally, if you want to migrate an existing theme, you should:
+
+1. Rename the `register` function to `xxx-theme` and remove the `self` parameter.
+2. Add a `show: touying-slides.with(..)` configuration.
+ - Change `self.methods.colors` to `config-colors(primary: rgb("#xxxxxx"))`.
+ - Change `self.page-args` to `config-page()`.
+ - Change `self.methods.slide = slide` to `config-methods(slide: slide)`.
+ - Change `self.methods.new-section-slide = new-section-slide` to `config-methods(new-section-slide: new-section-slide)`.
+ - Change private theme variables like `self.xxx-footer` to `config-store(footer: [..])`, which you can access through `self.store.footer`.
+ - Move the configuration of headers and footers into the `slide` function rather than in the `xxx-theme` function.
+ - You can directly use `set` or `show` rules in `xxx-theme` or configure them through `config-methods(init: (self: none, body) => { .. })` to fully utilize the `self` parameter.
+3. For `states.current-section-with-numbering`, you can use `utils.display-current-heading(level: 1)` instead.
+ - If you only need the previous heading regardless of whether it is a section or a subsection, use `utils.display-current-heading()`.
+4. The `alert` function can be replaced with `config-methods(alert: utils.alert-with-primary-color)`.
+5. The `touying-outline()` function is no longer needed; you can use `components.adaptive-columns(outline())` instead. Consider using `components.progressive-outline()` or `components.custom-progressive-outline()`.
+6. Replace `states.slide-counter.display() + " / " + states.last-slide-number` with `context utils.slide-counter.display() + " / " + utils.last-slide-number`. That is, we no longer use `states` but `utils`.
+7. Remove the `slides` function; we no longer need this function. Instead of implicitly injecting `title-slide()`, explicitly use `#title-slide()`. If necessary, consider adding it in the `xxx-theme` function.
+8. Change `#let slide(self: none, ..args) = { .. }` to `#let slide(..args) = touying-slide-wrapper(self => { .. })`, where `self` is automatically injected.
+ - Change specific parameter configurations to `self = utils.merge-dicts(self, config-page(fill: self.colors.neutral-lightest))`.
+ - Remove `self = utils.empty-page(self)` and use `config-common(freeze-slide-counter: true)` and `config-page(margin: 0em)` instead.
+ - Change `(self.methods.touying-slide)()` to `touying-slide()`.
+9. You can insert visible headings into slides by configuring `config-common(subslide-preamble: self => text(1.2em, weight: "bold", utils.display-current-heading(depth: self.slide-level)))`.
+10. Finally, don't forget to add document comments to your functions so your users can get better auto-completion hints, especially when using the Tinymist plugin.
+
+**Other Changes:**
+
+- theme(stargazer): new stargazer theme modified from [Coekjan/touying-buaa](https://github.com/Coekjan/touying-buaa).
+- feat: implemented fake frozen states support, allowing you to use numbering and `#pause` normally. This behavior can be controlled with `enable-frozen-states-and-counters`, `frozen-states`, and `frozen-counters` in `config-common()`.
+- feat: implemented `label-only-on-last-subslide` functionality to prevent non-unique label warnings when working with `@equation` and `@figure` in conjunction with `#pause` animations.
+- feat: added the `touying-recall(<label>)` function to replay a specific slide.
+- feat: implemented `nontight-list-enum-and-terms`, which defaults to `true` and forces `list`, `enum`, and `terms` to have their `tight` parameter set to `false`. You can control spacing size with `#set list(spacing: 1em)`.
+- feat: replaced `list` with `terms` implementation to achieve `align-list-marker-with-baseline`, which is off by default.
+- feat: implemented `scale-list-items`, scaling list items by a factor, e.g., `scale-list-items: 0.8` scales list items by 0.8.
+- feat: supported direct use of `#pause` and `#meanwhile` in math expressions, such as `$x + pause y$`.
+- feat: provided `#pause` and `#meanwhile` support for most layout functions, such as `grid` and `table`.
+- feat: added `#show: appendix` support, essentially equivalent to `#show: touying-set-config.with((appendix: true))`.
+- feat: Introduced special labels `<touying:hidden>`, `<touying:unnumbered>`, `<touying:unoutlined>`, `<touying:unbookmarked>` to simplify control over heading behavior.
+- feat: added basic `utils.short-heading` support to display short headings using labels, such as displaying `<sec:my-section>` as "My Section".
+- feat: added `#components.adaptive-columns()` to achieve adaptive columns that span a page, typically used with the `outline()` function.
+- feat: added `#show: magic.bibliography-as-footnote.with(bibliography("ref.bib"))` to display the bibliography in footnotes.
+- feat: added components like `custom-progressive-outline`, `mini-slides`.
+- feat: removed `touying-outline()`, which can be directly replaced with `outline()`.
+- fix: replaced potentially incompatible code, such as `type(s) == "string"` and `locate(loc => { .. })`.
+- fix: Fixed some bugs.
+
+
+
+## v0.4.2
+
+- theme(metropolis): decoupled text color with `neutral-dark` (Breaking change)
+- feat: add mark-style uncover, only and alternatives
+- feat: add warning for styled block for slides
+- feat: add warning for touying-temporary-mark
+- feat: add markup-text for speaker-note
+- fix: fix bug of slides
+
+
+## v0.4.1
+
+### Features
+
+- feat: support builtin outline and bookmark
+- feat: support speaker note for dual-screen
+- feat: add touying-mitex function
+- feat: touying offers [a gallery page](https://github.com/touying-typ/touying/wiki) via wiki
+
+### Fixes
+
+- fix: add outline-slide for dewdrop theme
+- fix: fix regression of default value "auto" for repeat
+
+### Miscellaneous Improvements
+
+- feat: add list support for `touying-outline` function
+- feat: add auto-reset-footnote
+- feat: add `freeze-in-empty-page` for better page counter
+- feat: add `..args` for register method to capture unused arguments
+
+
+## v0.4.0
+
+### Features
+
+- **feat:** support `#footnote[]` for all themes.
+- **feat:** access subslide and repeat in footer and header by `self => self.subslide`.
+- **feat:** support numbered theorem environments by [ctheorems](https://typst.app/universe/package/ctheorems).
+- **feat:** support numbering for sections and subsections.
+
+### Fixes
+
+- **fix:** make nested includes work correctly.
+- **fix:** disable multi-page slides from creating the same section multiple times.
+
+## Breaking changes
+
+- **refactor:** remove `self.padding` and add `self.full-header` `self.full-footer` config.
+
+
+## v0.3.3
+
+- **template:** move template to `touying-aqua` package, make Touying searchable in [Typst Universe Packages](https://typst.app/universe/search?kind=packages)
+- **themes:** fix bugs in university and dewdrop theme
+- **feat:** make set-show rule work without `setting` parameter
+- **feat:** make `composer` parameter more simpler
+- **feat:** add `empty-slide` function
+
+## v0.3.2
+
+- **fix critical bug:** fix `is-sequence` function, make `grid` and `table` work correctly in touying
+- **theme:** add aqua theme, thanks for pride7
+- **theme:** make university theme more configurable
+- **refactor:** don't export variable `s` by default anymore, it will be extracted by `register` function (**Breaking Change**)
+- **meta:** add `categories` and `template` config to `typst.toml` for Typst 0.11
+
+
+## v0.3.1
+
+- fix some typos
+- fix slide-level bug
+- fix bug of pdfpc label
+
+
+## v0.3.0
+
+### Features
+
+- better show-slides mode.
+- support align and pad.
+
+### Documentation
+
+- Add more detailed documentation.
+
+### Refactor
+
+- simplify theme.
+
+### Fix
+
+- fix many bugs.
+
+## v0.2.1
+
+### Features
+
+- **Touying-reducer**: support cetz and fletcher animation
+- **university theme**: add university theme
+
+### Fix
+
+- fix footer progress in metropolis theme
+- fix some bugs in simple and dewdrop themes
+- fix bug that outline does not display more than 4 sections
+
+
+## v0.2.0
+
+- **Object-oriented programming:** Singleton `s`, binding methods `utils.methods(s)` and `(self: obj, ..) => {..}` methods.
+- **Page arguments management:** Instead of using `#set page(..)`, you should use `self.page-args` to retrieve or set page parameters, thereby avoiding unnecessary creation of new pages.
+- **`#pause` for sequence content:** You can use #pause at the outermost level of a slide, including inline and list.
+- **`#pause` for layout functions:** You can use the `composer` parameter to add yourself layout function like `utils.side-by-side`, and simply use multiple pos parameters like `#slide[..][..]`.
+- **`#meanwhile` for synchronous display:** Provide a `#meanwhile` for resetting subslides counter.
+- **`#pause` and `#meanwhile` for math equation:** Provide a `#touying-equation("x + y pause + z")` for math equation animations.
+- **Slides:** Create simple slides using standard headings.
+- **Callback-style `uncover`, `only` and `alternatives`:** Based on the concise syntax provided by Polylux, allow precise control of the timing for displaying content.
+ - You should manually control the number of subslides using the `repeat` parameter.
+- **Transparent cover:** Enable transparent cover using oop syntax like `#let s = (s.methods.enable-transparent-cover)(self: s)`.
+- **Handout mode:** enable handout mode by `#let s = (s.methods.enable-handout-mode)(self: s)`.
+- **Fit-to-width and fit-to-height:** Fit-to-width for title in header and fit-to-height for image.
+ - `utils.fit-to-width(grow: true, shrink: true, width, body)`
+ - `utils.fit-to-height(width: none, prescale-width: none, grow: true, shrink: true, height, body)`
+- **Slides counter:** `states.slide-counter.display() + " / " + states.last-slide-number` and `states.touying-progress(ratio => ..)`.
+- **Appendix:** Freeze the `last-slide-number` to prevent the slide number from increasing further.
+- **Sections:** Touying's built-in section support can be used to display the current section title and show progress.
+ - `section` and `subsection` parameter in `#slide` to register a new section or subsection.
+ - `states.current-section-title` to get the current section.
+ - `states.touying-outline` or `s.methods.touying-outline` to display a outline of sections.
+ - `states.touying-final-sections(sections => ..)` for custom outline display.
+ - `states.touying-progress-with-sections((current-sections: .., final-sections: .., current-slide-number: .., last-slide-number: ..) => ..)` for powerful progress display.
+- **Navigation bar**: Navigation bar like [here](https://github.com/zbowang/BeamerTheme) by `states.touying-progress-with-sections(..)`, in `dewdrop` theme.
+- **Pdfpc:** pdfpc support and export `.pdfpc` file without external tool by `typst query` command simply.
--- /dev/null
+{
+ "label": "External Tools",
+ "position": 6,
+ "link": {
+ "type": "generated-index",
+ "description": "Integrate Touying with external presentation tools and editors."
+ }
+}
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# Gistd
+
+[Gistd](https://github.com/Myriad-Dreamin/gistd) instantly share [typst](https://typst.app) documents on git and other network storage. The most important feature is that it based on typst.ts to compile typst document, you can select and copy text!
+
+- [Global (Cloudflare CDN)](https://gistd.myriad-dreamin.com)
+- [Asia Region (Mirror, 亚洲区域镜像)](https://gistd-cn.myriad-dreamin.com)
+
+## Loading a document on GitHub
+
+Assuming that you have a GitHub link, for example:
+
+```
+https://github.com/typst/templates/blob/main/charged-ieee/template/main.typ
+```
+
+Simply replace the `github.com` with `gistd.myriad-dreamin.com`:
+
+```
+https://gistd.myriad-dreamin.com/typst/templates/blob/main/charged-ieee/template/main.typ
+```
+
+Example Documents:
+
+- [https://gistd.myriad-dreamin.com/johanvx/typst-undergradmath/blob/main/undergradmath.typ](https://gistd.myriad-dreamin.com/johanvx/typst-undergradmath/blob/main/undergradmath.typ)
+- [https://gistd.myriad-dreamin.com/Jollywatt/typst-fletcher/blob/main/docs/manual.typ](https://gistd.myriad-dreamin.com/Jollywatt/typst-fletcher/blob/main/docs/manual.typ)
+- [https://gistd.myriad-dreamin.com/typst/templates/blob/main/charged-ieee/template/main.typ](https://gistd.myriad-dreamin.com/typst/templates/blob/main/charged-ieee/template/main.typ)
+
+## View Parameters
+
+These URL parameters can change the behavior of gistd.
+
+- `g-page`: The page number to display. Default is `1`. Only available in the slide mode.
+- `g-mode`: The mode to display.
+ - `doc`: View the document in the document mode.
+ - `slide`: View the document in the slide mode.
+- `g-version`: The typst compiler version to use.
+ - Could be `v0.13.0`, `v0.13.1`, `v0.14.0`, or `latest`.
+
+## Slide View
+
+Using `g-mode=slide` mentioned above to view the document in the slide mode:
+
+Example Documents:
+
+- [A simple touying side.](https://gistd.myriad-dreamin.com/touying-typ/touying/blob/main/examples/simple.typ?g-mode=slide)
+
+## Loading a document by arbitrary links
+
+Assuming that you have arbitrary link, for example:
+
+```
+https://github.com/typst/templates/blob/main/charged-ieee/template/main.typ
+```
+
+Put `any-gistd.myriad-dreamin.com` before the link:
+
+```
+https://any-gistd.myriad-dreamin.com/github.com/typst/templates/blob/main/charged-ieee/template/main.typ
+```
+
+`any-gistd.myriad-dreamin.com` is alias for `gistd.myriad-dreamin.com/@any`.
+
+If a domain (host) is specially identified, gistd will use corresponding approach to serve the document.
+
+- `github.com`: git protocol.
+- `codeberg.org`: git protocol.
+- `localhost` and others: HTTP protocol if host is `localhost` otherwise https protocol. Note: gistd won't load other files on the specified domain if it cannot identifies the domain, i.e. the typst document cannot load other resources relative to the domain.
+
+Example Documents:
+
+- [https://any-gistd.myriad-dreamin.com/github.com/Myriad-Dreamin/gistd/raw/main/README.typ](https://any-gistd.myriad-dreamin.com/github.com/Myriad-Dreamin/gistd/raw/main/README.typ)
+- [https://gistd.myriad-dreamin.com/@any/github.com/Myriad-Dreamin/gistd/raw/main/README.typ](https://gistd.myriad-dreamin.com/@any/github.com/Myriad-Dreamin/gistd/raw/main/README.typ)
+
+## Loading a document without cors proxy
+
+By default, gistd uses a trusted cors proxy (`https://underleaf.mgt.workers.dev`) to load documents. This is because GitHub and Forgejo doesn't allow gistd to load documents. See [isomorphic-git: Quickstart](https://isomorphic-git.org/docs/en/quickstart) for more details.
+
+However, you may want to load a document without cors proxy. You can do this by adding `g-cors=false` to the query string.
+
+For example, to load a document at `http://localhost:11449/main.typ`:
+
+- [https://gistd.myriad-dreamin.com/@http/localhost:11449/main.typ?g-cors=false](https://gistd.myriad-dreamin.com/@http/localhost:11449/main.typ?g-cors=false)
+
+## Loading a document with HTTP protocol
+
+`@any` infers protocol from the URL, while you could use `@http` to force HTTP protocol. For example, to load a document at `http://localhost:11449/main.typ`:
+
+- [https://gistd.myriad-dreamin.com/@http/localhost:11449/main.typ?g-cors=false](https://gistd.myriad-dreamin.com/@http/localhost:11449/main.typ?g-cors=false)
+
+### Development
+
+Install dependencies:
+
+```
+pnpm install
+```
+
+Develop locally:
+
+```
+pnpm dev
+```
+
+Build:
+
+```
+pnpm build
+```
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# Pdfpc
+
+[pdfpc](https://pdfpc.github.io/) is a "Presenter Console with multi-monitor support for PDF files." This means you can use it to display slides in the form of PDF pages and it comes with some known excellent features, much like PowerPoint.
+
+pdfpc has a JSON-formatted `.pdfpc` file that can provide additional information for PDF slides. While you can manually write this file, you can also manage it through Touying.
+
+
+## Adding Metadata
+
+Touying remains consistent with [Polylux](https://polylux.dev/book/external/pdfpc.html) to avoid conflicts between APIs.
+
+For example, you can add notes using `#pdfpc.speaker-note("This is a note that only the speaker will see.")`.
+
+
+## Pdfpc Configuration
+
+To add pdfpc configurations, you can use
+
+```typst
+#pdfpc.config(
+ duration-minutes: 30,
+ start-time: datetime(hour: 14, minute: 10, second: 0),
+ end-time: datetime(hour: 14, minute: 40, second: 0),
+ last-minutes: 5,
+ note-font-size: 12,
+ disable-markdown: false,
+ default-transition: (
+ type: "push",
+ duration-seconds: 2,
+ angle: ltr,
+ alignment: "vertical",
+ direction: "inward",
+ ),
+)
+```
+
+Add the corresponding configurations. Refer to [Polylux](https://polylux.dev/book/external/pdfpc.html) for specific configuration details.
+
+
+## Exporting .pdfpc File
+
+Assuming your document is `./example.typ`, you can export the `.pdfpc` file directly using:
+
+```sh
+typst query --root . ./example.typ --field value --one "<pdfpc-file>" > ./example.pdfpc
+```
+
+With the compatibility of Touying and Polylux, you can make Polylux also support direct export by adding the following code:
+
+```typst
+#import "@preview/touying:0.7.3"
+
+#context touying.pdfpc.pdfpc-file(here())
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# Pympress
+
+[Pympress](https://github.com/Cimbali/pympress) is a PDF presentation tool designed for dual-screen setups such as presentations and public talks. Highly configurable, fully-featured, and portable
+
+
+## Speaker Notes
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-notes-on-second-screen: right),
+)
+
+= Animation
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+```
+
+
+
+Then we can use the pympress to show it.
+
+
+
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Touying Exporter
+
+[touying-exporter](https://github.com/touying-typ/touying-exporter) is a command-line tool that exports Touying presentations to various formats. It is designed to be used with Touying presentations, but it can also be used with other Typst files. Export presentation slides in various formats for Touying.
+
+## Touying Template
+
+[Touying template](https://github.com/touying-typ/touying-template) for online presentation in github pages.
+
+Demo: https://touying-typ.github.io/touying-template/
+
+To use this template, follow these steps:
+
+1. Click `Use this template` button to copy repo.
+2. Click `Settings -> Pages -> Branch -> None -> gh-pages -> Save` to enable github pages.
+3. Open link `your-name.github.io/repo-name` to start your presentation.
+
+Disadvantages: Cannot select and copy text, if needed, please use [Gistd](https://github.com/Myriad-Dreamin/gistd).
+
+## HTML Export
+
+We generate SVG image files and package them with impress.js into an HTML file. This way, you can open and present it using a browser, and it supports GIF animations and speaker notes.
+
+
+
+
+
+[Touying template](https://github.com/touying-typ/touying-template) for online presentation. [Online](https://touying-typ.github.io/touying-template/)
+
+## PPTX Export
+
+We generate PNG image files and package them into a PPTX file. This way, you can open and present it using PowerPoint, and it supports speaker notes.
+
+
+
+## Install
+
+```sh
+pip install touying
+```
+
+
+## CLI
+
+```text
+usage: touying compile [-h] [--output OUTPUT] [--root ROOT] [--font-paths [FONT_PATHS ...]] [--start-page START_PAGE] [--count COUNT] [--ppi PPI] [--silent SILENT] [--format {html,pptx,pdf,pdfpc}] [--sys-inputs SYS_INPUTS] input
+
+positional arguments:
+ input Input file
+
+options:
+ -h, --help show this help message and exit
+ --output OUTPUT Output file
+ --root ROOT Root directory for typst file
+ --font-paths [FONT_PATHS ...]
+ Paths to custom fonts
+ --start-page START_PAGE
+ Page to start from
+ --count COUNT Number of pages to convert
+ --ppi PPI Pixels per inch for PPTX format
+ --silent SILENT Run silently
+ --format {html,pptx,pdf,pdfpc}
+ Output format
+ --sys-inputs SYS_INPUTS
+ JSON string to pass to typst's sys.inputs
+```
+
+For example:
+
+```sh
+touying compile example.typ
+```
+
+You will get a `example.html` file. Open it with your browser and start your presentation :-)
+
+### Passing variables to typst
+
+You can pass variables to your typst files using the `--sys-inputs` parameter:
+
+```sh
+touying compile example.typ --sys-inputs '{"title":"My Presentation","author":"John Doe"}'
+```
+
+In your typst file, you can access these variables like this:
+
+```typst
+#let title = sys.inputs.at("title", default: "Default Title")
+#let author = sys.inputs.at("author", default: "Default Author")
+
+= #title
+By #author
+```
+
+
+## Use it as a python package
+
+```python
+import touying
+
+touying.to_html("example.typ")
+```
--- /dev/null
+---
+sidebar_position: 5
+---
+
+# Typst Preview in Tinymist
+
+The Tinymist extension for VS Code provides an excellent slide mode, allowing us to preview and present slides.
+
+Press `Ctrl/Cmd + Shift + P` and type `Typst Preview: Preview current file in slide mode` to open the preview in slide mode.
+
+Press `Ctrl/Cmd + Shift + P` and type `Typst Preview: Preview current file in browser and slide mode` to open the slide mode in the browser.
+
+Now, you can press keys like `F11` to enter fullscreen mode in the browser, making it suitable for slide presentations.
+
+Since Typst Preview is based on SVG, it can play GIF animations, which is very helpful for dynamic slides.
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# Frequently Asked Questions
+
+## Themes and Configuration
+
+### How do I choose or switch themes?
+
+Import a theme and apply it with `#show`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+= Section
+
+== Slide
+
+This uses the simple theme.
+```
+
+Available themes: `simple`, `default`, `metropolis`, `aqua`, `dewdrop`, `stargazer`, `university`.
+
+### How do I customize theme colors?
+
+Pass a `config-colors(...)` argument to your theme:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-colors(primary: rgb("#d94f00")),
+ config-info(title: [Custom Color], author: [Author]),
+)
+
+= Section
+
+== Slide
+
+The header now uses the custom primary color.
+```
+
+---
+
+## Layout and Columns
+
+### How do I create a two-column layout?
+
+Use `slide` with a `composer` argument to split content into columns:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide(composer: (1fr, 1fr))[
+ == Left Column
+
+ Some text on the left side.
+][
+ == Right Column
+
+ Some text on the right side.
+]
+```
+
+For unequal widths, adjust the fractions, e.g. `(2fr, 1fr)`.
+
+### How do I place content at an absolute position?
+
+Use Typst's `place` function for absolute positioning:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ Main slide content here.
+
+ #place(bottom + right, dx: -1em, dy: -1em)[
+ #rect(fill: blue.lighten(80%), inset: 0.5em)[Note]
+ ]
+]
+```
+
+### How do I fit content to fill the remaining slide height or width?
+
+Use `utils.fit-to-height` or `utils.fit-to-width`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ #utils.fit-to-width(1fr)[
+ == This heading fills the slide width
+ ]
+
+ Some content below.
+]
+```
+
+---
+
+## Table of Contents
+
+### How do I display a table of contents?
+
+Use `components.adaptive-columns` wrapping Typst's built-in `outline`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= First Section
+
+== Introduction
+
+Hello, Touying!
+
+= Second Section
+
+== Details
+
+More content here.
+```
+
+The `<touying:hidden>` label hides the outline slide from the outline itself.
+
+### How do I add numbering to sections in the outline?
+
+Use the `numbly` package together with `#set heading(numbering: ...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#import "@preview/numbly:0.1.0": numbly
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= First Section
+
+== First Slide
+
+= Second Section
+
+== Second Slide
+```
+
+### How do I show a progressive/highlighted outline?
+
+Use `components.progressive-outline` to highlight the current section:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+
+#show: dewdrop-theme.with(aspect-ratio: "16-9")
+
+= First Section
+
+== Outline
+
+#components.progressive-outline()
+
+= Second Section
+
+== Slide
+```
+
+---
+
+## Bibliography and Citations
+
+### How do I show citations as footnotes?
+
+Pass a `bibliography(...)` value to `config-common(show-bibliography-as-footnote: ...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#let bib = bytes(
+ "@book{knuth,
+ title={The Art of Computer Programming},
+ author={Donald E. Knuth},
+ year={1968},
+ publisher={Addison-Wesley},
+ }",
+)
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-bibliography-as-footnote: bibliography(bib)),
+)
+
+= Citations
+
+== Footnote Example
+
+This is a famous book. @knuth
+```
+
+### How do I add a bibliography slide at the end?
+
+Use `magic.bibliography(...)` to display a references slide:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#let bib = bytes(
+ "@book{knuth,
+ title={The Art of Computer Programming},
+ author={Donald E. Knuth},
+ year={1968},
+ publisher={Addison-Wesley},
+ }",
+)
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-bibliography-as-footnote: bibliography(bib)),
+)
+
+= Intro
+
+== Slide
+
+Some cited content. @knuth
+
+== References
+
+#magic.bibliography(title: none)
+```
+
+---
+
+## Speaker Notes
+
+### How do I add speaker notes to a slide?
+
+Use the `#speaker-note[...]` function anywhere in a slide:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ == My Slide
+
+ Visible content here.
+
+ #speaker-note[
+ - Remind the audience of the previous topic.
+ - Emphasize the key takeaway.
+ - Time check: should be at 10 min mark.
+ ]
+]
+```
+
+Speaker notes do not appear in the slide output by default.
+
+### How do I show speaker notes on a second screen?
+
+Use `config-common(show-notes-on-second-screen: right)` to show notes beside the slides:
+
+```typst
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-notes-on-second-screen: right),
+)
+```
+
+This is compatible with presenter tools like [pdfpc](https://pdfpc.github.io/) and [pympress](https://github.com/Cimbali/pympress).
+
+---
+
+## Slide Numbering and Appendix
+
+### How do I display slide numbers in the footer?
+
+Use `utils.slide-counter.display()` for the current slide number and `utils.last-slide-number` for the total:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ footer: context [
+ #utils.slide-counter.display() / #utils.last-slide-number
+ ],
+ ),
+)
+
+= Section
+
+== First Slide
+
+The footer shows the slide number.
+
+== Second Slide
+
+Still counting.
+```
+
+### How do I mark a slide as unnumbered?
+
+Add the `<touying:unnumbered>` label to the heading:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+= Title Slide <touying:unnumbered>
+
+== Welcome
+
+This slide is not counted.
+
+== Normal Slide
+
+This slide is counted.
+```
+
+### How do I use an appendix so it doesn't affect the slide count?
+
+Apply `#show: appendix` after your main content. Slides after this point do not increment the slide counter:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Main Content
+
+== Introduction
+
+This is slide 1.
+
+== Results
+
+This is slide 2.
+
+#show: appendix
+
+= Appendix
+
+== Extra Material
+
+This slide is in the appendix and does not increment the main counter.
+```
+
+---
+
+## Animations and Dynamic Content
+
+### How do I use `#pause` to reveal content step by step?
+
+Place `#pause` between content blocks within a `#slide`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ First point.
+
+ #pause
+
+ Second point revealed on click.
+
+ #pause
+
+ Third point revealed on second click.
+]
+```
+
+### How do I show content only on specific subslides?
+
+Use `#only("...")` to show content on particular subslides, or `#uncover("...")` to show it while reserving its space:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ #only("1")[Shown on subslide 1 only.]
+ #only("2-")[Shown from subslide 2 onward.]
+ #uncover("3-")[Revealed on subslide 3, space reserved before.]
+]
+```
+
+### Why doesn't `#pause` work inside a `context` expression?
+
+`#pause` uses metadata injection that does not work inside `context { ... }` blocks. Use the callback-style `slide` instead to access `self.subslide`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide(self => {
+ let (uncover, only) = utils.methods(self)
+ [First content.]
+ linebreak()
+ uncover("2-")[Revealed on subslide 2.]
+ linebreak()
+ only("3")[Only on subslide 3.]
+})
+```
+
+### How do I use `#pause` inside a CeTZ drawing?
+
+Use `touying-reducer` to wrap CeTZ canvas so Touying can animate it:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#import "@preview/cetz:0.5.0"
+
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+
+#slide[
+ #cetz-canvas({
+ import cetz.draw: *
+ rect((0, 0), (4, 3))
+ (pause,)
+ circle((2, 1.5), radius: 1)
+ })
+]
+```
+
+### How do I use `#pause` inside a Fletcher diagram?
+
+Use `touying-reducer` to wrap Fletcher diagrams:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge
+
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+
+#slide[
+ #fletcher-diagram(
+ node((0, 0), [A]),
+ edge("->"),
+ (pause,),
+ node((1, 0), [B]),
+ )
+]
+```
+
+### How do I show alternative content across subslides?
+
+Use `#alternatives` to swap between different content versions:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ The answer is: #alternatives[42][*forty-two*][_the ultimate answer_].
+]
+```
+
+### How do I enable handout mode (no animations)?
+
+Set `config-common(handout: true)` in your theme setup:
+
+```typst
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(handout: true),
+)
+```
+
+In handout mode, only the final subslide of each slide is output.
+
+---
+
+## Fonts and Text
+
+### How do I change the font for my presentation?
+
+Use a `#set text(...)` rule before or after your theme setup:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-info(title: [Custom Font]),
+)
+
+#set text(font: "New Computer Modern", size: 22pt)
+
+= Section
+
+== Slide
+
+Text now uses the custom font.
+```
+
+For math, also set the math font:
+
+```typst
+#show math.equation: set text(font: "New Computer Modern Math")
+```
+
+### How do I justify paragraph text?
+
+Use `#set par(justify: true)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#set par(justify: true)
+
+#slide[
+ == Justified Text
+
+ #lorem(40)
+]
+```
+
+---
+
+## Headings and Sections
+
+### How do I disable automatic section slides?
+
+Set `config-common(new-section-slide-fn: none)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-common(new-section-slide-fn: none),
+ config-info(title: [No Auto Sections]),
+)
+
+= Section
+
+== Slide
+
+No automatic section slide was created for the `= Section` heading.
+```
+
+### How do I write content for sections that have section slides?
+
+Use `pagebreak()` or `---` to force a new page for that section and write there.
+
+```example
+>>>#import "@preview/touying:0.7.3": *
+>>>#import themes.metropolis: *
+>>>
+>>>#show: metropolis-theme.with(
+>>> aspect-ratio: "16-9",
+>>> config-info(title: [content slides next to section slides]),
+>>>)
+
+= Section
+---
+Here is my content for this section.
+
+== Slide
+And this works normally.
+```
+
+You may also set `config-common(receive-body-for-new-section-slide-fn: false)`. This however will prevent you from writing speaker-notes for the section slide.
+
+### How do I hide a slide from the presentation output entirely?
+
+Add the `<touying:hidden>` label to the slide heading:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+== Visible Slide
+
+This slide appears in the output.
+
+== Hidden Slide <touying:hidden>
+
+This slide is hidden and does not appear in the output or outline.
+
+== Another Visible Slide
+
+Back to normal.
+```
+
+### How do I exclude a slide from the outline but still show it?
+
+Use the `<touying:unoutlined>` label:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= Section
+
+== Normal Slide
+
+Appears in the outline.
+
+== Interstitial Slide <touying:unoutlined>
+
+This slide shows but is not listed in the outline.
+
+== Another Normal Slide
+
+Also appears in the outline.
+```
+
+### How do I control which heading level creates a new slide?
+
+Use `config-common(slide-level: ...)`. The default varies by theme:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(slide-level: 2),
+)
+
+= Section
+
+This text is part of the section slide.
+
+== Subsection Slide
+
+Each `==` heading creates a new slide.
+
+=== Sub-subheading
+
+Sub-subheadings do not create new slides.
+```
+
+### How do I add a custom header or footer?
+
+Use `config-page(header: ..., footer: ...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ header: text(gray)[My Custom Header],
+ footer: context align(right, text(gray)[
+ Slide #utils.slide-counter.display()
+ ]),
+ ),
+)
+
+= Section
+
+== Slide
+
+Slide with a custom header and footer.
+```
+
+---
+
+## config-common Reference
+
+### How do I prevent slide content from overflowing to the next page?
+
+Use `config-common(breakable: false)` to prevent slide content from automatically overflowing to the next page. By default (`breakable: true`), content that exceeds the slide height creates new pages. When set to `false`, content is constrained to a single page using a non-breakable block, which is useful for ensuring a strict one-to-one mapping between source slides and output pages — especially in agentic workflows where an agent needs to reason about slide boundaries.
+
+Related parameters:
+
+- **`clip`** (default `false`): When `true`, content that exceeds the slide height is visually truncated.
+- **`detect-overflow`** (default `true`): When `true`, a layout measurement is performed and `panic()` is called if the content height exceeds the available slide height, making it easy to catch overflow early. Set to `false` to avoid the extra layout overhead.
+
+```typst
+// Prevent overflow, panic on overflow (default behavior when breakable: false)
+#show: simple-theme.with(
+ config-common(breakable: false),
+)
+
+// Prevent overflow and visually clip overflowing content
+#show: simple-theme.with(
+ config-common(breakable: false, clip: true),
+)
+
+// Prevent overflow, disable overflow detection (performance-first)
+#show: simple-theme.with(
+ config-common(breakable: false, detect-overflow: false),
+)
+```
+
+You can also switch these settings mid-presentation using `touying-set-config`:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme.with(config-common(breakable: false))
+== This slide's overflow will be clipped
+
+// Enable clipping for a specific slide
+#show: touying-set-config.with(config-common(clip: true))
+
+#lorem(500)
+```
+
+### How do I use a semi-transparent cover instead of fully hiding content?
+
+Use `config-methods(cover: utils.semi-transparent-cover)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-methods(cover: utils.semi-transparent-cover),
+)
+
+= Section
+
+== Slide
+
+#pause
+This content is shown with a semi-transparent cover.
+```
+
+### How do I use preamble to insert content before every slide?
+
+Use `config-common(preamble: ...)` and `subslide-preamble`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(
+ preamble: text(gray)[This appears before every slide],
+ subslide-preamble: (2: [Special prelude for subslide 2]),
+ ),
+)
+
+= Section
+
+== Slide
+
+Content here.
+
+#pause
+
+More content.
+```
+
+### How do I use `---` to separate slides?
+
+Set `horizontal-line-to-pagebreak: true` (default) and use `---` to create page breaks:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(horizontal-line-to-pagebreak: true),
+)
+
+= First Section
+
+== Slide One
+
+Content here.
+
+---
+
+== Slide Two
+
+Separated by horizontal rule.
+```
+
+---
+
+## Testing and Development
+
+### How do I run Touying's test suite?
+
+Touying uses the [tytanic](https://github.com/Myriad-Dreamin/tytanic) test framework. Install it with:
+
+```bash
+cargo binstall tytanic
+```
+
+Run tests with:
+
+```bash
+tt run
+```
+
+Tests are organized in the `tests/` directory:
+
+- `features/` — Feature tests for core functionality
+- `themes/` — Theme-specific tests
+- `integration/` — Third-party package integration tests (cetz, fletcher, pinit, theorion, codly, mitex)
+- `issues/` — Regression tests for reported issues
+- `examples/` — Example tests from the documentation
+
+### How do I contribute to Touying?
+
+To contribute to Touying:
+
+1. Fork the repository on GitHub
+2. Create a new branch for your changes
+3. Make your changes following the existing code style
+4. Format your code with [typstyle](https://github.com/Myriad-Dreamin/typstyle)
+5. Run `tt run` to ensure all tests pass
+6. Submit a pull request with a clear description of your changes
+
+---
+
+## Miscellaneous
+
+### How do I set the presentation title, author, date, etc.?
+
+Use `config-info(...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [My Presentation],
+ subtitle: [A Subtitle],
+ author: [Jane Doe],
+ date: datetime.today(),
+ institution: [My University],
+ contact: [contact\@mail.com],
+ ),
+)
+
+#title-slide()
+
+= Introduction
+
+== First Slide
+
+Content here.
+```
+
+### How do I override the config for a single slide?
+
+Use `touying-set-config` around the content you want to change:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ Normal slide.
+]
+
+#touying-set-config(config-page(fill: rgb("#fff3cd")))[
+ #slide[
+ This slide has a yellow background.
+ ]
+]
+
+#slide[
+ Back to normal.
+]
+```
+
+
+### How do I access global or slide config information?
+
+Use `touying-get-config` at the position you want to know the config. The config is only accessible during `context` time, thus computing with this may lead to problems.
+> If you write this inside a slide that has local config, the local config will be returned instead of the global config.
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme.with(
+ config-info(author: "Beautiful Name")
+)
+#slide(config:config-colors(primary: rgb("ABCDEF")))[
+ #touying-get-config().info.author
+
+ #touying-get-config().colors.primary
+]
+```
+
+### How do I compile my Touying presentation?
+
+Touying is a pure Typst package — there is no separate build step:
+
+```bash
+typst compile slides.typ
+```
+
+For live preview during editing:
+
+```bash
+typst watch slides.typ
+```
+
+Or use the [Typst Preview](https://marketplace.visualstudio.com/items?itemName=mgt19937.typst-preview) VS Code extension for instant in-editor preview.
+
+### How do I create a multi-file presentation?
+
+Import `lib.typ` from the main entry file and use `include` for sections:
+
+```typst
+// main.typ
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+#include "intro.typ"
+#include "methods.typ"
+#include "results.typ"
+```
+
+Each included file uses headings normally — no extra imports needed in each file.
+
+### How do I display the current section name in the header or footer?
+
+Use `utils.display-current-heading(...)` or `utils.display-current-short-heading(...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ header: context text(gray)[
+ #utils.display-current-heading(level: 1)
+ ],
+ ),
+)
+
+= My Section
+
+== Slide
+
+The header shows the current section name.
+```
+
+### How do I use Touying with the `pinit` package for pin annotations?
+
+Import both packages and use `#pin`/`#pinit-highlight` inside slides as normal:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/pinit:0.2.2": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+#slide[
+ A #pin(1)key term#pin(2) to highlight.
+
+ #pinit-highlight(1, 2)
+]
+```
+
+For animated pin reveals, use the callback-style slide so `#pause` interacts correctly with pinit.
+
+### How do I freeze counters (figures, equations) across subslides?
+
+Use `config-common(frozen-counters: true)` to prevent counters from advancing between subslides:
+
+```typst
+#show: simple-theme.with(
+ config-common(frozen-counters: true),
+)
+```
+
+### How do I disable/enable warnings?
+
+Touying uses `uniwarn` for its warnings with the namespace `touying`.
+We bind the functions into touying so you can directly do
+
+```typst
+#import "@preview/touying:0.7.1": *
+
+//to disable the warnings emitted by touying
+#touying-disable-warnings
+//to reenable the warnings emitted by touying
+#touying-enable-warnings
+```
+
+But you can also do
+
+```typst
+#import "@preview/uniwarn:0.1.0"
+#uniwarn.disable-warnings("touying")
+#uniwarn.enable-warnings("touying")
+```
\ No newline at end of file
--- /dev/null
+{
+ "label": "Package Integration",
+ "position": 4,
+ "link": {
+ "type": "generated-index",
+ "description": "Discover how to integrate third-party Typst packages with Touying for enhanced functionality."
+ }
+}
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# CeTZ
+
+Touying provides the `touying-reducer`, which adds `pause` and `meanwhile` animations to CeTZ and Fletcher.
+
+## Simple Animation
+
+An example:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: metropolis-theme.with(aspect-ratio: "16-9")
+
+// cetz animation
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0,0), (5,5))
+
+ (pause,)
+
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+
+ (pause,)
+
+ line((0,0), (2.5, 2.5), name: "line")
+ })
+]
+
+// fletcher animation
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1,0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0,0), `reading`, radius: 2em),
+ edge((0,0), (0,0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1,0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2,0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0,0), (2,0), `close()`, "-|>", bend: -40deg),
+ )
+]
+```
+
+
+## only and uncover
+
+In fact, we can also use `only` and `uncover` within CeTZ, but it requires a bit of technique:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/cetz:0.5.0"
+#import themes.simple: *
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ Cetz in Touying in subslide #self.subslide:
+
+ #cetz.canvas({
+ import cetz.draw: *
+ let uncover = uncover.with(cover-fn: hide.with(bounds: true))
+
+ rect((0,0), (5,5))
+
+ uncover("2-3", {
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+ })
+
+ only(3, line((0,0), (2.5, 2.5), name: "line"))
+ })
+])
+```
--- /dev/null
+---
+sidebar_position: 5
+---
+
+# Codly
+
+[Codly](https://github.com/Dherse/codly) is a Typst package that provides beautiful code blocks with language icons, line numbers, and syntax highlighting.
+
+## Setup
+
+Because Touying re-renders content on every subslide, `codly`'s per-page state must be restored before each slide is drawn. Pass the codly initialization as a `preamble`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#import "@preview/codly:1.3.0": *
+#import "@preview/codly-languages:0.1.10": *
+
+#show: codly-init.with()
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(preamble: {
+ codly(languages: codly-languages)
+ }),
+)
+
+== First Slide
+
+#raw(lang: "rust", block: true,
+`pub fn main() {
+ println!("Hello, world!");
+}`.text)
+```
+
+## Animated Code Blocks
+
+You can reveal code line-by-line using `#pause` or `#only`. However, remember that `#pause` does not work inside a `raw` block directly — use `touying-raw` for animated code:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+== Animated Raw Block
+
+#touying-raw(lang: "python", ```
+print("Step 1")
+// pause
+print("Step 2")
+// pause
+print("Step 3")
+```)
+```
+
+:::tip
+
+`touying-raw` uses special comment markers (`// pause`, `// meanwhile`) to trigger animation steps, keeping the source readable.
+
+:::
+
+## Codly + Animation
+
+For a fully styled codly block with animation, combine `touying-raw` with `config-common(preamble: ...)`:
+
+```typst
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(preamble: {
+ codly(languages: codly-languages)
+ }),
+)
+
+== Animated Code
+
+#touying-raw(lang: "python", ```
+def greet(name):
+// pause
+ return f"Hello, {name}!"
+// pause
+print(greet("World"))
+```)
+```
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# Fletcher
+
+Touying provides the `touying-reducer`, which adds `pause` and `meanwhile` animations to Fletcher.
+
+An example:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: metropolis-theme.with(aspect-ratio: "16-9")
+
+// cetz animation
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0,0), (5,5))
+
+ (pause,)
+
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+
+ (pause,)
+
+ line((0,0), (2.5, 2.5), name: "line")
+ })
+]
+
+// fletcher animation
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1,0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0,0), `reading`, radius: 2em),
+ edge((0,0), (0,0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1,0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2,0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0,0), (2,0), `close()`, "-|>", bend: -40deg),
+ )
+]
+```
+
+An example with callback-style:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/fletcher:0.5.8" as fletcher: diagram, edge, node
+#show: themes.simple.simple-theme.with(aspect-ratio: "16-9")
+
+#let diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#slide(repeat: 6, self => {
+ let (uncover, only, alternatives) = utils.methods(self)
+ let uncover = uncover.with(cover-fn: fletcher.hide)
+ diagram(
+ node((0, 0), name: <A>)[$A$],
+ pause,
+ edge("->"),
+ node((1, 0), name: <B>)[$B$],
+ pause,
+ edge("->"),
+ node((2, 0), name: <C>)[$C$],
+ uncover("4,6", edge(<A>, "~", <B>, bend: 40deg, stroke: red)),
+ only("5,6", edge(<B>, "~", <C>, bend: 40deg, stroke: green)),
+ only("6", edge(<C>, "~", <A>, bend: 40deg, stroke: blue)),
+ )
+})
+```
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# MiTeX
+
+During the process of creating slides, we often already have a LaTeX math equation that we simply want to paste into the slides without transcribing it into a Typst math equation. In such cases, we can use [MiTeX](https://github.com/mitex-rs/mitex).
+
+Example:
+
+```example
+#import "@preview/mitex:0.2.6": *
+
+Write inline equations like #mi("x") or #mi[y].
+
+Also block equations (this case is from #text(blue.lighten(20%), link("https://katex.org/")[katex.org])):
+
+#mitex(`
+ \newcommand{\f}[2]{#1f(#2)}
+ \f\relax{x} = \int_{-\infty}^\infty
+ \f\hat\xi\,e^{2 \pi i \xi x}
+ \,d\xi
+`)
+```
+
+Touying also provides a `touying-mitex` function, which can be used for example
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/mitex:0.2.6": *
+#import themes.simple: *
+#show: simple-theme
+
+#touying-mitex(mitex, `
+ f(x) &= \pause x^2 + 2x + 1 \\
+ &= \pause (x + 1)^2 \\
+`)
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Pinit
+
+[Pinit](https://github.com/OrangeX4/typst-pinit/) package provides the ability to perform absolute positioning based on the page and relative positioning based on "pins," making it convenient to implement arrow pointing and explanatory effects for slides.
+
+## Simple Example
+
+```example
+#import "@preview/pinit:0.2.2": *
+
+#set text(size: 24pt)
+
+A simple #pin(1)highlighted text#pin(2).
+
+#pinit-highlight(1, 2)
+
+#pinit-point-from(2)[It is simple.]
+```
+
+Another [example](https://github.com/OrangeX4/typst-pinit/blob/main/examples/equation-desc.typ):
+
+
+
+
+## Complex Example
+
+An example of shared usage with Touying:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+#import "@preview/pinit:0.2.2": *
+
+#set text(size: 20pt, font: "Calibri", ligatures: false)
+#show heading: set text(weight: "regular")
+#show heading: set block(above: 1.4em, below: 1em)
+#show heading.where(level: 1): set text(size: 1.5em)
+
+// Useful functions
+#let crimson = rgb("#c00000")
+#let greybox(..args, body) = rect(fill: luma(95%), stroke: 0.5pt, inset: 0pt, outset: 10pt, ..args, body)
+#let redbold(body) = {
+ set text(fill: crimson, weight: "bold")
+ body
+}
+#let blueit(body) = {
+ set text(fill: blue)
+ body
+}
+
+#show: default-theme.with(aspect-ratio: "4-3")
+
+// Main body
+#slide[
+ #set heading(offset: 0)
+
+ = Asymptotic Notation: $O$
+
+ Use #pin("h1")asymptotic notations#pin("h2") to describe asymptotic efficiency of algorithms.
+ (Ignore constant coefficients and lower-order terms.)
+
+ #pause
+
+ #greybox[
+ Given a function $g(n)$, we denote by $O(g(n))$ the following *set of functions*:
+ #redbold(${f(n): "exists" c > 0 "and" n_0 > 0, "such that" f(n) <= c dot g(n) "for all" n >= n_0}$)
+ ]
+
+ #pinit-highlight("h1", "h2")
+
+ #pause
+
+ $f(n) = O(g(n))$: #pin(1)$f(n)$ is *asymptotically smaller* than $g(n)$.#pin(2)
+
+ #pause
+
+ $f(n) redbold(in) O(g(n))$: $f(n)$ is *asymptotically* #redbold[at most] $g(n)$.
+
+ #only("4-", pinit-line(stroke: 3pt + crimson, start-dy: -0.25em, end-dy: -0.25em, 1, 2))
+
+ #pause
+
+ #block[Insertion Sort as an #pin("r1")example#pin("r2"):]
+
+ - Best Case: $T(n) approx c n + c' n - c''$ #pin(3)
+ - Worst case: $T(n) approx c n + (c' \/ 2) n^2 - c''$ #pin(4)
+
+ #pinit-rect("r1", "r2")
+
+ #pause
+
+ #pinit-place(3, dx: 15pt, dy: -15pt)[#redbold[$T(n) = O(n)$]]
+ #pinit-place(4, dx: 15pt, dy: -15pt)[#redbold[$T(n) = O(n)$]]
+
+ #pause
+
+ #blueit[Q: Is $n^(3) = O(n^2)$#pin("que")? How to prove your answer#pin("ans")?]
+
+ #pause
+
+ #pinit-point-to("que", fill: crimson, redbold[No.])
+ #pinit-point-from("ans", body-dx: -150pt)[
+ Show that the equation $(3/2)^n >= c$ \
+ has infinitely many solutions for $n$.
+ ]
+]
+```
--- /dev/null
+---
+sidebar_position: 6
+---
+
+# Theorion
+
+Touying can work properly with the [Theorion](https://github.com/OrangeX4/typst-theorion) package, you can directly use the [theorion](https://github.com/OrangeX4/typst-theorion) package. Additionally, you can use `#set heading(numbering: "1.1")` to set numbering for sections and subsections.
+
+**Note: To make animation commands like `#pause` work properly with theorion, you need to use `config-common(frozen-counters: (theorem-counter,))` to bind counters that need to be frozen.**
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+#import "@preview/numbly:0.1.0": numbly
+#import "@preview/theorion:0.6.0": *
+#import cosmos.clouds: *
+#show: show-theorion
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-common(frozen-counters: (theorem-counter,)), // freeze theorem counter for animation
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+= Theorems
+
+== Prime numbers
+
+#definition[
+ A natural number is called a #highlight[_prime number_] if it is greater
+ than 1 and cannot be written as the product of two smaller natural numbers.
+]
+#example[
+ The numbers $2$, $3$, and $17$ are prime.
+ @cor_largest_prime shows that this list is not exhaustive!
+]
+
+#pause
+
+#theorem(title: "Euclid")[
+ There are infinitely many primes.
+]
+
+#pagebreak(weak: true)
+
+#proof[
+ Suppose to the contrary that $p_1, p_2, dots, p_n$ is a finite enumeration
+ of all primes. Set $P = p_1 p_2 dots p_n$. Since $P + 1$ is not in our list,
+ it cannot be prime. Thus, some prime factor $p_j$ divides $P + 1$. Since
+ $p_j$ also divides $P$, it must divide the difference $(P + 1) - P = 1$, a
+ contradiction.
+]
+
+#corollary[
+ There is no largest prime number.
+] <cor_largest_prime>
+#corollary[
+ There are infinitely many composite numbers.
+]
+
+#theorem[
+ There are arbitrarily long stretches of composite numbers.
+]
+
+#proof[
+ For any $n > 2$, consider $
+ n! + 2, quad n! + 3, quad ..., quad n! + n
+ $
+]
+```
+## Why `frozen-counters` is Required
+
+Touying renders each subslide by re-evaluating slide content. Without `frozen-counters`, Theorion's internal `theorem-counter` would increment on every subslide, causing theorem numbers to jump unexpectedly.
+
+`config-common(frozen-counters: (theorem-counter,))` tells Touying to capture the counter value at the start of each slide and restore it before rendering each subslide, so theorem numbers remain consistent across animation steps.
+
+## Multiple Counter Types
+
+If you also have figure counters that should be frozen:
+
+```typst
+config-common(frozen-counters: (theorem-counter, figure.where(kind: image)))
+```
+
+## Cosmos Styles
+
+Theorion ships with several visual styles. The example above uses `cosmos.clouds`. Others include `cosmos.fancy`, `cosmos.aurora`, and more. See the [Theorion documentation](https://github.com/OrangeX4/typst-theorion) for details.
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Introduction to Touying
+
+[Touying](https://github.com/touying-typ/touying) is a powerful slide/presentation package for [Typst](https://typst.app/). It is similar to LaTeX Beamer but benefits from Typst's modern syntax and fast compilation. Throughout this documentation we use **slides** to refer to the whole slideshow, **slide** for a single page, and **subslide** for a sub-page produced by an animation step.
+
+## Why Use Touying?
+
+- **vs. PowerPoint** — Touying follows a "content and style separation" philosophy. You write plain text with lightweight markup, and themes handle the visual design automatically. This is especially productive for research-heavy presentations with code blocks, mathematical formulas, and theorem environments.
+- **vs. Markdown Slides** — Typst gives you fine-grained typesetting control (headers, footers, custom layouts, and first-class math support) that Markdown-based tools struggle to provide. Touying adds `#pause` and `#meanwhile` for incremental animations that feel natural in a code-first workflow.
+- **vs. Beamer** — Touying compiles in milliseconds instead of seconds (or tens of seconds). Its syntax is far more concise, and creating or modifying a theme is straightforward. Feature parity with Beamer is high, plus Touying offers extras like `touying-reducer` for animated CeTZ/Fletcher diagrams.
+- **vs. Polylux** — Touying does not rely on `counter` and `locate` to implement `#pause`, so it avoids the performance penalty those primitives incur. It also provides a richer set of theme utilities and a unified config API that lets you switch themes with minimal changes to your document.
+
+## About the Name
+
+"Touying" (投影, tóuyǐng) means "projection" in Chinese — just as the German word *Beamer* means projector in LaTeX's world.
+
+## Where to Write Your Slides
+
+You have two main options:
+
+| Option | Description |
+|--------|-------------|
+| **[Typst Web App](https://typst.app/)** | Browser-based editor. No installation needed; just open `typst.app`, create a new project, and start writing. Supports real-time preview and collaboration. |
+| **[Tinymist for VS Code](https://marketplace.visualstudio.com/items?itemName=myriad-dreamin.tinymist)** | A full-featured Typst LSP extension for VS Code. Provides syntax highlighting, autocomplete, error diagnostics, and a built-in slide preview panel. |
+
+Both options automatically download Touying from the Typst package registry — no separate installation step is required.
+
+## Universe
+
+Tired of [built-in themes](https://touying-typ.github.io/themes/)?
+
+[Typst Universe](https://typst.app/universe/search/?q=touying) has a wide variety of themes uploaded by touying users, take a look, maybe you'll like it.
+
+
+## Gallery
+
+Touying provides [a gallery](https://github.com/touying-typ/touying/wiki) where community members share their slides. You are encouraged to contribute your own work!
+
+## Contribution
+
+Touying is free, open-source, and community-driven. Visit [GitHub](https://github.com/touying-typ/touying) to open issues, submit pull requests, or join the [touying-typ](https://github.com/touying-typ) organization.
+
+## License
+
+Touying is released under the [MIT license](https://github.com/touying-typ/touying/blob/main/LICENSE).
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# Getting Started
+
+Before you begin, make sure you have the Typst environment installed. If not, you can use the [Web App](https://typst.app/) or install the [Tinymist LSP](https://marketplace.visualstudio.com/items?itemName=myriad-dreamin.tinymist) plugins for VS Code.
+
+To use Touying, you just need to include the following in your document:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
+
+It's that simple! You've created your first Touying slides. Congratulations! 🎉
+
+**Tip:** You can use Typst syntax like `#import "config.typ": *` or `#include "content.typ"` to implement Touying's multi-file architecture.
+
+## More Complex Examples
+
+In fact, Touying provides various styles for slide writing. You can also use the `#slide[..]` syntax to access more powerful features provided by Touying.
+
+Touying offers many built-in themes to easily create beautiful slides. For example, in this case:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+#import "@preview/numbly:0.1.0": numbly
+#import "@preview/theorion:0.6.0": *
+#import cosmos.clouds: *
+#show: show-theorion
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ // align: horizon,
+ // config-common(handout: true),
+ config-common(frozen-counters: (theorem-counter,)), // freeze theorem counter for animation
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= Animation
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+
+== Complex Animation
+
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+
+
+== Callback Style Animation
+
+#slide(
+ repeat: 3,
+ self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+ ],
+)
+
+
+== Math Equation Animation
+
+Equation with `pause`:
+
+$
+ f(x) &= pause x^2 + 2x + 1 \
+ &= pause (x + 1)^2 \
+$
+
+#meanwhile
+
+Here, #pause we have the expression of $f(x)$.
+
+#pause
+
+By factorizing, we can obtain this result.
+
+
+== CeTZ Animation
+
+CeTZ Animation in Touying:
+
+#cetz-canvas({
+ import cetz.draw: *
+
+ rect((0, 0), (5, 5))
+
+ (pause,)
+
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+
+ (pause,)
+
+ line((0, 0), (2.5, 2.5), name: "line")
+})
+
+
+== Fletcher Animation
+
+Fletcher Animation in Touying:
+
+#fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1, 0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0, 0), `reading`, radius: 2em),
+ edge((0, 0), (0, 0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1, 0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2, 0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0, 0), (2, 0), `close()`, "-|>", bend: -40deg),
+)
+
+
+= Theorems
+
+== Prime numbers
+
+#definition[
+ A natural number is called a #highlight[_prime number_] if it is greater
+ than 1 and cannot be written as the product of two smaller natural numbers.
+]
+#example[
+ The numbers $2$, $3$, and $17$ are prime.
+ @cor_largest_prime shows that this list is not exhaustive!
+]
+
+#theorem(title: "Euclid")[
+ There are infinitely many primes.
+]
+#pagebreak(weak: true)
+#proof[
+ Suppose to the contrary that $p_1, p_2, dots, p_n$ is a finite enumeration
+ of all primes. Set $P = p_1 p_2 dots p_n$. Since $P + 1$ is not in our list,
+ it cannot be prime. Thus, some prime factor $p_j$ divides $P + 1$. Since
+ $p_j$ also divides $P$, it must divide the difference $(P + 1) - P = 1$, a
+ contradiction.
+]
+
+#corollary[
+ There is no largest prime number.
+] <cor_largest_prime>
+#corollary[
+ There are infinitely many composite numbers.
+]
+
+#theorem[
+ There are arbitrarily long stretches of composite numbers.
+]
+
+#proof[
+ For any $n > 2$, consider $
+ n! + 2, quad n! + 3, quad ..., quad n! + n
+ $
+]
+
+
+= Others
+
+== Multiple columns
+
+#cols[
+ First column.
+][
+ Second column.
+]
+
+== Multiple columns with equal height blocks
+
+#cols(columns: (1fr, 1fr), gutter: 1em)[
+ #emph-block[
+ First column with equal height: #lorem(10)
+ #lazy-v(1fr)
+ ]
+][
+ #emph-block[
+ Second column with equal height: : #lorem(15)
+ #lazy-v(1fr)
+ ]
+]
+
+
+== Multiple Pages
+
+#lorem(200)
+
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
+```
+
+Touying offers many built-in themes to easily create beautiful slides. For example, `#show: university-theme.with()` uses the university theme. For more detailed tutorials on themes, you can refer to the following sections.
\ No newline at end of file
--- /dev/null
+{
+ "label": "Themes",
+ "position": 5,
+ "link": {
+ "type": "generated-index",
+ "description": "Explore the built-in themes included with Touying and learn how to create your own custom themes."
+ }
+}
--- /dev/null
+---
+sidebar_position: 5
+---
+
+# Aqua Theme
+
+This theme is created by [@pride7](https://github.com/pride7), featuring beautiful backgrounds made with Typst's visualization capabilities.
+
+## Initialization
+
+You can initialize it with the following code:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.aqua: *
+
+#show: aqua-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#title-slide()
+
+#outline-slide()
+```
+
+The `register` function in the Aqua theme accepts the following parameters:
+
+- `aspect-ratio`: The aspect ratio of the slides, which can be "16-9" or "4-3", with a default of "16-9".
+- `header`: The content displayed in the header of the slides, with a default of `utils.display-current-heading()`. You can also provide a function like `self => self.info.title` to customize the header content.
+- `footer`: The content displayed on the right side of the footer, with a default of `context utils.slide-counter.display()`.
+
+Additionally, the Aqua theme provides a `#alert[..]` function, which you can use with the `#show strong: alert` syntax to emphasize text within your slides.
+
+## Color Theme
+
+The Aqua theme uses the following color scheme by default:
+
+```typst
+config-colors(
+ primary: rgb("#003F88"),
+ primary-light: rgb("#2159A5"),
+ primary-lightest: rgb("#F2F4F8"),
+ neutral-lightest: rgb("#FFFFFF"),
+)
+```
+
+You can modify this color scheme using the `config-colors()` function to suit your preferences or to match the branding of your presentation.
+
+
+## Slide Function Family
+
+Aqua theme offers a series of custom slide functions:
+
+```typst
+#title-slide(..args)
+```
+
+`title-slide` will read information from `self.info` for display.
+
+---
+
+```typst
+#let outline-slide(self: none, enum-args: (:), leading: 50pt)
+```
+
+Display an outline slide.
+
+---
+
+```typst
+#slide(
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+ // Aqua theme
+ title: auto,
+)[
+ ...
+]
+```
+
+A default ordinary slide function with title and footer, where `title` defaults to the current section title.
+
+---
+
+```typst
+#focus-slide[
+ ...
+]
+```
+
+Used to draw the audience's attention. The background color is `self.colors.primary`.
+
+---
+
+```typst
+#new-section-slide(title)
+```
+
+Start a new section with the given title.
+
+
+## Example
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.aqua: *
+
+#show: aqua-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#title-slide()
+
+#outline-slide()
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+
+#focus-slide[
+ Another variant with primary color in background...
+]
+
+== Summary
+
+#slide(self => [
+ #align(center + horizon)[
+ #set text(size: 3em, weight: "bold", fill: self.colors.primary)
+ THANKS FOR ALL
+ ]
+])
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 7
+---
+
+# Custom Theme
+
+If none of the built-in themes quite fits your needs, you have two options:
+
+1. **Extend an existing theme** — copy a theme file locally and modify it.
+2. **Build a new theme from scratch** — implement your own `xxx-theme` function.
+
+Both approaches are described in detail in the [Build Your Own Theme](../tutorials/build-your-own-theme.md) tutorial.
+
+## Quick Modifications
+
+For minor adjustments to an existing theme, you do not need to create a separate theme file. You can override individual settings inline:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ // Override the primary color
+ config-colors(primary: rgb("#1a6b8a")),
+ // Change the footer content
+ footer: self => self.info.author,
+ config-info(
+ title: [My Presentation],
+ author: [Author Name],
+ date: datetime.today(),
+ ),
+)
+
+#title-slide()
+
+= Section
+
+== Slide
+
+Content with the custom color.
+```
+
+## Copying a Theme Locally
+
+To make deeper structural changes, copy the theme source file to your project:
+
+1. Download the relevant file from `themes/` in the Touying repository (e.g., `themes/metropolis.typ`).
+2. Change the import at the top from `#import "../src/exports.typ": *` to `#import "@preview/touying:0.7.3": *`.
+3. Import the local copy instead of the built-in theme.
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import "metropolis.typ": * // your local copy
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-info(title: [Title]),
+)
+```
+
+You can now freely edit `metropolis.typ` without affecting other projects.
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# Dewdrop Theme
+
+This theme takes inspiration from Zhibo Wang's [BeamerTheme](https://github.com/zbowang/BeamerTheme) and has been modified by [OrangeX4](https://github.com/OrangeX4).
+
+The Dewdrop theme features an elegantly designed navigation, including two modes: `sidebar` and `mini-slides`.
+
+## Initialization
+
+You can initialize it using the following code:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ navigation: "mini-slides",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ ),
+)
+
+#title-slide()
+
+#outline-slide()
+```
+
+The `register` function in the Dewdrop theme accepts the following parameters:
+
+- `aspect-ratio`: The aspect ratio of the slides, which can be "16-9" or "4-3", with a default of "16-9".
+- `navigation`: The style of the navigation bar, which can be `"sidebar"`, `"mini-slides"`, or `none`, with a default of `"sidebar"`.
+- `sidebar`: Settings for the sidebar navigation, with default values of `(width: 10em, filled: false, numbered: false, indent: .5em, short-heading: true)`.
+- `mini-slides`: Settings for the mini-slides navigation, with default values of `(height: 4em, x: 2em, display-section: false, display-subsection: true, short-heading: true)`.
+ - `height`: The height of the mini-slides, with a default of `2em`.
+ - `x`: The x-axis padding for the mini-slides, with a default of `2em`.
+ - `section`: Whether to display slides after the section and before the subsection, with a default of `false`.
+ - `subsection`: Whether to separate mini-slides based on subsections, with a default of `true`. Setting this to `false` will squash them into a single line.
+- `footer`: The content displayed in the footer of the slides, with a default of an empty array `[]`. You can customize it with a function, such as `self => self.info.author`.
+- `footer-right`: The content displayed on the right side of the footer, with a default of `context utils.slide-counter.display() + " / " + utils.last-slide-number`.
+- `primary`: The primary color of the theme, with a default of `rgb("#0c4842")`.
+- `alpha`: The transparency level, with a default of `70%`.
+
+Additionally, the Dewdrop theme provides a `#alert[..]` function, which you can use with the `#show strong: alert` syntax to create emphasized alert text.
+
+## Color Theme
+
+The Dewdrop theme uses the following color scheme by default:
+
+```typc
+config-colors(
+ neutral-darkest: rgb("#000000"),
+ neutral-dark: rgb("#202020"),
+ neutral-light: rgb("#f3f3f3"),
+ neutral-lightest: rgb("#ffffff"),
+ primary: primary,
+)
+```
+
+You can modify this color scheme using the `config-colors()` function. This allows you to tailor the color palette of your slides to match the aesthetic you're aiming for or to conform to a specific branding guideline.
+
+
+## Slide Function Family
+
+The Dewdrop theme provides a variety of custom slide functions:
+
+```typst
+#title-slide(extra: none, ..args)
+```
+
+`title-slide` reads information from `self.info` for display, and you can also pass in an `extra` parameter to display additional information.
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+)[
+ ...
+]
+```
+
+A default slide with navigation and footer, where the footer is what you set.
+
+---
+
+```typst
+#focus-slide[
+ ...
+]
+```
+
+Used to draw attention, with the background color set to `self.colors.primary`.
+
+## Example
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ navigation: "mini-slides",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+$ x_(n+1) = (x_n + a/x_n) / 2 $
+
+== Subsection A.2
+
+A slide without a title but with *important* infos
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
+```
+
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# Metropolis Theme
+
+This theme draws inspiration from Matthias Vogelgesang's [Metropolis beamer](https://github.com/matze/mtheme) theme and has been modified by [Enivex](https://github.com/Enivex).
+
+The Metropolis theme is elegant and suitable for everyday use. It is recommended to have Fira Sans and Fira Math fonts installed on your computer for the best results.
+
+## Initialization
+
+You can initialize it using the following code:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.city,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+```
+
+The `metropolis-theme` in the theme accepts the following parameters:
+
+- `aspect-ratio`: The aspect ratio of the slides, which can be "16-9" or "4-3", with a default of "16-9".
+- `align`: The alignment of the content within the slides, with a default of `horizon` (horizontal alignment).
+- `header`: The content displayed in the header of the slides, with a default that displays the current heading adjusted to fit the width (`utils.display-current-heading(setting: utils.fit-to-width.with(grow: false, 100%))`). Alternatively, you can provide a function like `self => self.info.title` to customize the header content.
+- `header-right`: The content displayed on the right side of the header, with a default that shows the logo specified in `self.info.logo`.
+- `footer`: The content displayed in the footer of the slides, with a default of an empty array `[]`. You can customize it with a function, for example, to display the author's information: `self => self.info.author`.
+- `footer-right`: The content displayed on the right side of the footer, with a default that shows the slide number and the total number of slides (`context utils.slide-counter.display() + " / " + utils.last-slide-number`).
+- `footer-progress`: A boolean value indicating whether to display a progress bar at the bottom of the slides, with a default of `true`.
+
+
+
+## Color Theme
+
+Metropolis uses the following default color theme:
+
+```typc
+config-colors(
+ primary: rgb("#eb811b"),
+ primary-light: rgb("#d6c6b7"),
+ secondary: rgb("#23373b"),
+ neutral-lightest: rgb("#fafafa"),
+ neutral-dark: rgb("#23373b"),
+ neutral-darkest: rgb("#23373b"),
+)
+```
+
+You can modify this color theme using `config-colors()`.
+
+## Slide Function Family
+
+The Metropolis theme provides a variety of custom slide functions:
+
+```typst
+#title-slide(extra: none, ..args)
+```
+
+`title-slide` reads information from `self.info` for display, and you can also pass in an `extra` parameter to display additional information.
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+ // metropolis theme
+ title: auto,
+ footer: auto,
+ align: horizon,
+)[
+ ...
+]
+```
+
+A default slide with headers and footers, where the title defaults to the current section title, and the footer is what you set.
+
+---
+
+```typst
+#focus-slide[
+ ...
+]
+```
+
+Used to draw attention, with the background color set to `self.colors.primary-dark`.
+
+---
+
+```typst
+#new-section-slide(short-title: auto, title)
+```
+
+Creates a new section with the given title.
+
+## Example
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.city,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+= Outline <touying:hidden>
+
+#outline(title: none, indent: 1em, depth: 1)
+
+= First Section
+
+---
+
+A slide without a title but with some *important* information.
+
+== A long long long long long long long long long long long long long long long long long long long long long long long long Title
+
+=== sdfsdf
+
+A slide with equation:
+
+$ x_(n+1) = (x_n + a/x_n) / 2 $
+
+#lorem(200)
+
+= Second Section
+
+#focus-slide[
+ Wake up!
+]
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+#show: appendix
+
+= Appendix
+
+---
+
+Please pay attention to the current slide number.
+```
+
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Simple Theme
+
+This theme originates from [Polylux](https://polylux.dev/book/themes/gallery/simple.html), created by Andreas Kröpelin.
+
+Considered a relatively straightforward theme, you can use it to create simple slides and freely incorporate features you like.
+
+## Initialization
+
+You can initialize it using the following code:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ footer: [Simple slides],
+)
+```
+
+The `register` function in the theme accepts the following parameters:
+
+- `aspect-ratio`: The aspect ratio of the slides, which can be "16-9" or "4-3", with a default of "16-9".
+- `header`: The content displayed in the header, with a default of `utils.display-current-heading(setting: utils.fit-to-width.with(grow: false, 100%))`. You can also pass a function like `self => self.info.title`.
+- `header-right`: The content displayed on the right side of the header, with a default of `self => self.info.logo`.
+- `footer`: The content displayed in the footer, with a default of `[]` (empty). You can also pass a function like `self => self.info.author`.
+- `footer-right`: The content displayed on the right side of the footer, with a default of `context utils.slide-counter.display() + " / " + utils.last-slide-number`.
+- `primary`: The primary color of the theme, with a default of `aqua.darken(50%)`.
+- `subslide-preamble`: By default, it adds the subsection title to the current slide.
+
+
+## Slide Function Family
+
+The Simple theme provides a variety of custom slide functions:
+
+```typst
+#centered-slide(section: ..)[
+ ...
+]
+```
+
+A slide with content centered, and the `section` parameter can be used to create a new section.
+
+---
+
+```typst
+#title-slide[
+ ...
+]
+```
+
+Similar to `centered-slide`, this is provided for consistency with Polylux syntax.
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+)[
+ ...
+]
+```
+
+A default slide with headers and footers, where the header corresponds to the current section, and the footer is what you set.
+
+---
+
+```typst
+#focus-slide(foreground: ..., background: ...)[
+ ...
+]
+```
+
+Used to draw attention, it optionally accepts a foreground color (defaulting to `white`) and a background color (defaulting to `auto`, i.e., `self.colors.primary`).
+
+
+## Example
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ footer: [Simple slides],
+)
+
+#title-slide[
+ = Keep it simple!
+ #v(2em)
+
+ Alpha #footnote[Uni Augsburg] #h(1em)
+ Bravo #footnote[Uni Bayreuth] #h(1em)
+ Charlie #footnote[Uni Chemnitz] #h(1em)
+
+ July 23
+]
+
+== First slide
+
+#lorem(20)
+
+#focus-slide[
+ _Focus!_
+
+ This is very important.
+]
+
+= Let's start a new section!
+
+== Dynamic slide
+
+Did you know that...
+
+#pause
+
+...you can see the current section at the top of the slide?
+```
+
--- /dev/null
+---
+sidebar_position: 6
+---
+
+# Stargazer Theme
+
+The Stargazer theme, originally created by [Coekjan](https://github.com/Coekjan/) for the [touying-buaa](https://github.com/Coekjan/touying-buaa) project, is an aesthetically pleasing and versatile theme suitable for everyday use.
+
+## Initialization
+
+You can initialize the theme with the following code:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.stargazer: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: stargazer-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Stargazer in Touying: Customize Your Slide Title Here],
+ subtitle: [Customize Your Slide Subtitle Here],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+```
+
+The `stargazer-theme` accepts the following parameters:
+
+- `aspect-ratio`: The aspect ratio of the slides, either "16-9" or "4-3", with a default of "16-9".
+- `align`: The alignment of the slides, with a default of `horizon`.
+- `alpha`: The transparency of the slides, with a default of `20%`.
+- `title`: The content displayed in the header, with a default of `utils.display-current-heading()`, or you can pass a function like `self => self.info.title`.
+- `progress-bar`: Whether to display a progress bar at the bottom of the slide, with a default of `true`.
+- `footer-columns`: The widths of the three footer columns, with a default of `(25%, 25%, 1fr, 5em)`.
+- `footer-a`: The first column, with a default of `self => self.info.author`.
+- `footer-b`: The second column, with a default of `self => utils.display-info-date(self)`.
+- `footer-c`: The third column, with a default of `self => if self.info.short-title == auto { self.info.title } else { self.info.short-title }`.
+- `footer-d`: The fourth column, with a default of `context utils.slide-counter.display() + " / " + utils.last-slide-number`.
+
+## Color Theme
+
+The Stargazer theme uses the following color scheme by default:
+
+```typc
+config-colors(
+ primary: rgb("#005bac"),
+ primary-dark: rgb("#004078"),
+ secondary: rgb("#ffffff"),
+ tertiary: rgb("#005bac"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+)
+```
+
+You can modify this color scheme using `config-colors()`.
+
+## Slide Function Family
+
+The Stargazer theme offers a variety of custom slide functions:
+
+```typst
+#title-slide(extra: none, ..args)
+```
+
+`title-slide` reads information from `self.info` for display, and you can also pass an `extra` parameter for additional information.
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+ // stargazer theme
+ title: auto,
+ footer: auto,
+ align: horizon,
+)[
+ ...
+]
+```
+
+A standard slide function with a title and footer by default, where `title` defaults to the current section title, and the footer is the one you set.
+
+---
+
+```typst
+#outline-slide[
+ ...
+]
+```
+
+Used to add a table of contents slide.
+
+---
+
+```typst
+#focus-slide[
+ ...
+]
+```
+
+Used to draw the audience's attention. The background color is `self.colors.primary-dark`.
+
+---
+
+```typst
+#new-section-slide(short-title: auto, title)
+```
+
+Start a new section with the given title.
+
+## Example
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.stargazer: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: stargazer-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Stargazer in Touying: Customize Your Slide Title Here],
+ subtitle: [Customize Your Slide Subtitle Here],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+#tblock(title: [Theorem])[
+ A simple theorem.
+
+ $ x_(n+1) = (x_n + a / x_n) / 2 $
+]
+
+== Subsection A.2
+
+A slide without a title but with *important* information.
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
+```
+
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# University Theme
+
+This aesthetically pleasing theme is courtesy of [Pol Dellaiera](https://github.com/drupol).
+
+## Initialization
+
+You can initialize the theme with the following code:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+```
+
+The `register` function accepts the following parameters:
+
+- `aspect-ratio`: The aspect ratio of the slides, either "16-9" or "4-3", with a default of "16-9".
+- `progress-bar`: Whether to display a progress bar at the top of the slide, with a default of `true`.
+- `header`: The content displayed in the header, with a default of `utils.display-current-heading(level: 2)`, or you can pass a function like `self => self.info.title`.
+- `header-right`: The content displayed on the right side of the header, with a default of `self => self.info.logo`.
+- `footer-columns`: The widths of the three columns in the footer, with a default of `(25%, 1fr, 25%)`.
+- `footer-a`: The first column, with a default of `self => self.info.author`.
+- `footer-b`: The second column, with a default of `self => if self.info.short-title == auto { self.info.title } else { self.info.short-title }`.
+- `footer-c`: The third column, with a default of
+
+```typst
+self => {
+ h(1fr)
+ utils.display-info-date(self)
+ h(1fr)
+ context utils.slide-counter.display() + " / " + utils.last-slide-number
+ h(1fr)
+}
+```
+
+## Color Theme
+
+The University theme uses the following color scheme by default:
+
+```typc
+config-colors(
+ primary: rgb("#04364A"),
+ secondary: rgb("#176B87"),
+ tertiary: rgb("#448C95"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+)
+```
+
+You can modify this color scheme using `config-colors()`.
+
+## Slide Function Family
+
+The University theme provides a series of custom slide functions:
+
+```typst
+#title-slide(logo: none, authors: none, ..args)
+```
+
+The `title-slide` function reads information from `self.info` for display, and you can also pass a `logo` parameter and an array-type `authors` parameter.
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+ // university theme
+ title: none,
+)[
+ ...
+]
+```
+
+A standard slide function with a title and footer by default, where `title` defaults to the current section title, and the footer is the one you set.
+
+### Focus Slide
+
+```typst
+#focus-slide(background-img: ..., background-color: ...)[
+ ...
+]
+```
+
+Used to capture the audience's attention. The default background color is `self.colors.primary`.
+
+### Matrix Slide
+
+```typst
+#matrix-slide(columns: ..., rows: ...)[
+ ...
+][
+ ...
+]
+```
+
+Refer to the [documentation](https://polylux.dev/book/themes/gallery/university.html).
+
+## Example
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide(authors: ([Author A], [Author B]))
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+
+#focus-slide[
+ Another variant with primary color in background...
+]
+
+#matrix-slide[
+ left
+][
+ middle
+][
+ right
+]
+
+#matrix-slide(columns: 1)[
+ top
+][
+ bottom
+]
+
+#matrix-slide(columns: (1fr, 2fr, 1fr), ..(lorem(8),) * 9)
+```
+
--- /dev/null
+{
+ "label": "Tutorials",
+ "position": 3,
+ "link": {
+ "type": "generated-index",
+ "description": "Step-by-step tutorials covering the major features of Touying."
+ }
+}
--- /dev/null
+---
+sidebar_position: 9
+---
+
+# Build Your Own Theme
+
+Creating your own theme with Touying can be a bit complex due to the many concepts we've introduced. But rest assured, if you do create a theme with Touying, you might deeply appreciate the convenience and powerful customizability that Touying offers. You can refer to the [source code of the themes](https://github.com/touying-typ/touying/tree/main/themes). The main things you need to implement are:
+
+- Customizing the `xxx-theme` function;
+- Customizing the color theme, i.e., `config-colors()`;
+- Customizing the header;
+- Customizing the footer;
+- Customizing the `slide` method;
+- Customizing special slide methods, such as `title-slide` and `focus-slide` methods;
+
+To demonstrate how to create a theme with Touying, let's step by step create a simple and aesthetically pleasing Bamboo theme.
+
+## Modifying Existing Themes
+
+If you want to modify a Touying internal theme locally instead of creating one from scratch, you can achieve this by:
+
+1. Copying the [theme code](https://github.com/touying-typ/touying/tree/main/themes) from the `themes` directory to your local, for example, copying `themes/university.typ` to your local `university.typ`.
+2. Replacing the `#import "../src/exports.typ": *` command at the top of the `university.typ` file with `#import "@preview/touying:0.7.3": *`.
+
+Then you can import and use the theme by:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import "university.typ": *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+```
+
+## Importing
+
+Depending on whether the theme is your own or part of Touying, you can import it in two ways:
+
+If it's just for your own use, you can directly import Touying:
+
+```typst
+#import "@preview/touying:0.7.3": *
+```
+
+If you want the theme to be part of Touying, placed in the Touying `themes` directory, then you should change the import statement above to
+
+```typst
+#import "../src/exports.typ": *
+```
+
+And add
+
+```typst
+#import "bamboo.typ"
+```
+
+in Touying's `themes/themes.typ`.
+
+## register Function and init Method
+
+Next, we will differentiate between the `bamboo.typ` template file and the `main.typ` file, which is sometimes omitted.
+
+Generally, the first step in making slides is to determine the font size and page aspect ratio, so we need to register an initialization method:
+
+```example
+// bamboo.typ
+#import "@preview/touying:0.7.3": *
+
+#let bamboo-theme(
+ aspect-ratio: "16-9",
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(paper: "presentation-" + aspect-ratio),
+ config-common(
+ slide-fn: slide,
+ ),
+ ..args,
+ )
+
+ body
+}
+
+// main.typ
+<<< #import "@preview/touying:0.7.3": *
+<<< #import "bamboo.typ": *
+
+#show: bamboo-theme.with(aspect-ratio: "16-9")
+
+= First Section
+
+== First Slide
+
+A slide with a title and an *important* information.
+```
+
+As you can see, we've created a `bamboo-theme` function and passed in an `aspect-ratio` parameter to set the page aspect ratio. We've also added `set text(size: 20pt)` to set the font size. You can also place some additional global style settings here, such as `set par(justify: true)`, etc. If you need to use `self`, you might consider using `config-methods(init: (self: none, body) => { .. })` to register an `init` method.
+
+As you can see, later in `main.typ`, we apply our style settings through `#show: bamboo-theme.with(aspect-ratio: "16-9")`, and internally `bamboo` uses `show: touying-slides.with()` for corresponding configurations.
+
+## Color Theme
+
+Picking an aesthetically pleasing color theme for your slides is key to making good slides. Touying provides built-in color theme support to minimize API differences between different themes. Touying offers two dimensions of color selection. The first dimension is `neutral`, `primary`, `secondary`, and `tertiary`, which are used to distinguish color tones, with `primary` being the most commonly used theme color. The second dimension is `default`, `light`, `lighter`, `lightest`, `dark`, `darker`, `darkest`, which are used to distinguish brightness levels.
+
+Since we are creating the Bamboo theme, we have chosen a color close to bamboo for the `primary` theme color, `rgb("#5E8B65")`, and added neutral colors `neutral-lightest`, `neutral-darkest`, respectively, as the background and font colors.
+
+As shown in the following code, we can use the `config-colors()` method to modify the color theme. Its essence is a wrapper for `self.colors += (..)`.
+
+```typst
+#let bamboo-theme(
+ aspect-ratio: "16-9",
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(paper: "presentation-" + aspect-ratio),
+ config-common(
+ slide-fn: slide,
+ ),
+ config-colors(
+ primary: rgb("#5E8B65"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ ),
+ ..args,
+ )
+
+ body
+}
+```
+
+After adding the color theme as shown above, we can access this color through `self.colors.primary`.
+
+It's also worth noting that users can change the color theme at any time in `main.typ` by using `config-colors()` or
+
+```typst
+#show: touying-set-config.with(config-colors(
+ primary: blue,
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+))
+```
+
+This feature of being able to change the color theme at any time is a testament to Touying's powerful customizability.
+
+## Practical: Custom Alert Method
+
+Generally, we need to provide a `#alert[..]` function for users, similar to `#strong[..]`, both of which are used to emphasize the current text. Typically, `#alert[..]` will change the text color to the theme color, which will look more aesthetically pleasing, and this is our next goal.
+
+We add a line in the `register` function:
+
+```typst
+config-methods(alert: (self: none, it) => text(fill: self.colors.primary, it))
+```
+
+This code means to change the text color to `self.colors.primary`, and the `self` here is passed in through the parameter `self: none`, so that we can get the `primary` theme color in real-time.
+
+We can also use a shorthand.
+
+```typst
+config-methods(alert: utils.alert-with-primary-color)
+```
+
+## Custom Header and Footer
+
+Here, I assume you have read the page layout section, so we know that we should add a header and footer to the slides.
+
+First, we add `config-store(title: none)`, which means that we save the current slide's title as a member variable `self.store.title` inside `self`, making it convenient for us to use in the header and for subsequent modifications. Similarly, we also create a `config-store(footer: footer)` and save the `footer: none` parameter of the `bamboo-theme` function for display in the footer at the bottom left corner.
+
+Then it's worth noting that our header is actually a content function with `self` as a parameter, like `let header(self) = { .. }`, rather than a simple content, so that we can get the information we need from the latest `self`, such as `self.store.title`. The footer is the same.
+
+The `components.cell` used here is actually `#let cell = block.with(width: 100%, height: 100%, above: 0pt, below: 0pt, breakable: false)`, and `show: components.cell` is also a shorthand for `components.cell(body)`, and the `show: pad.with(.4em)` for the footer is the same.
+
+Another point to note is that the `utils` module contains many contents and methods related to counters and states, such as `utils.display-current-heading(level: 1)` for displaying the current `section`, and `context utils.slide-counter.display() + " / " + utils.last-slide-number` for displaying the current page number and total number of pages.
+
+We also find that we use syntax like `utils.call-or-display(self, self.store.footer)` to display `self.store.footer`, which is to deal with the situation of `self.store.footer = self => {..}`, so that we can unify the display of content functions and content.
+
+To ensure that the header and footer are displayed correctly and have enough spacing from the main text, we need to set the margin, such as `config-page(margin: (top: 4em, bottom: 1.5em, x: 2em))`.
+
+We also need to customize a `slide` method, which accepts `#let slide(title: auto, ..args) = touying-slide-wrapper(self => {..})`, where `self` in the callback function is a required parameter to get the latest `self`; the second `title` is used to update `self.store.title` for display in the header; the third `..args` is used to collect the remaining parameters and pass them to `touying-slide(self: self, ..args)`, which is also necessary for the normal functioning of Touying's `slide` feature. Moreover, we need to register this method in the `bamboo-theme` function using `config-methods(slide: slide)`.
+
+```example
+// bamboo.typ
+#import "@preview/touying:0.7.3": *
+
+#let slide(title: auto, ..args) = touying-slide-wrapper(self => {
+ if title != auto {
+ self.store.title = title
+ }
+ // set page
+ let header(self) = {
+ set align(top)
+ show: components.cell.with(fill: self.colors.primary, inset: 1em)
+ set align(horizon)
+ set text(fill: self.colors.neutral-lightest, size: .7em)
+ utils.display-current-heading(level: 1)
+ linebreak()
+ set text(size: 1.5em)
+ if self.store.title != none {
+ utils.call-or-display(self, self.store.title)
+ } else {
+ utils.display-current-heading(level: 2)
+ }
+ }
+ let footer(self) = {
+ set align(bottom)
+ show: pad.with(.4em)
+ set text(fill: self.colors.neutral-darkest, size: .8em)
+ utils.call-or-display(self, self.store.footer)
+ h(1fr)
+ context utils.slide-counter.display() + " / " + utils.last-slide-number
+ }
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ header: header,
+ footer: footer,
+ ),
+ )
+ touying-slide(self: self, ..args)
+})
+
+#let bamboo-theme(
+ aspect-ratio: "16-9",
+ footer: none,
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(
+ paper: "presentation-" + aspect-ratio,
+ margin: (top: 4em, bottom: 1.5em, x: 2em),
+ ),
+ config-common(
+ slide-fn: slide,
+ ),
+ config-methods(
+ alert: utils.alert-with-primary-color,
+ ),
+ config-colors(
+ primary: rgb("#5E8B65"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ ),
+ config-store(
+ title: none,
+ footer: footer,
+ ),
+ ..args,
+ )
+
+ body
+}
+
+
+// main.typ
+<<< #import "@preview/touying:0.7.3": *
+<<< #import "bamboo.typ": *
+
+#show: bamboo-theme.with(aspect-ratio: "16-9")
+
+= First Section
+
+== First Slide
+
+A slide with a title and an *important* information.
+```
+
+## Custom Special Slides
+
+On the basis of the basic slides we've created, we further add some special slide functions, such as `title-slide`, `focus-slide`, and custom `slides` methods.
+
+For the `title-slide` method, first, we can obtain the information saved in `self.info` through `let info = self.info + args.named()`, and we can also update the information with `args.named()` passed in through the function parameters for subsequent use in the form of `info.title`. The specific page content `body` will vary for each theme, so I won't go into too much detail here.
+
+For the `new-section-slide` method, it's the same, but the only thing to note is that we registered `new-section-slide-fn: new-section-slide` in `config-methods()`, so `new-section-slide` will be automatically called when encountering a first-level heading.
+
+```example
+// bamboo.typ
+#import "@preview/touying:0.7.3": *
+
+#let slide(title: auto, ..args) = touying-slide-wrapper(self => {
+ if title != auto {
+ self.store.title = title
+ }
+ // set page
+ let header(self) = {
+ set align(top)
+ show: components.cell.with(fill: self.colors.primary, inset: 1em)
+ set align(horizon)
+ set text(fill: self.colors.neutral-lightest, size: .7em)
+ utils.display-current-heading(level: 1)
+ linebreak()
+ set text(size: 1.5em)
+ if self.store.title != none {
+ utils.call-or-display(self, self.store.title)
+ } else {
+ utils.display-current-heading(level: 2)
+ }
+ }
+ let footer(self) = {
+ set align(bottom)
+ show: pad.with(.4em)
+ set text(fill: self.colors.neutral-darkest, size: .8em)
+ utils.call-or-display(self, self.store.footer)
+ h(1fr)
+ context utils.slide-counter.display() + " / " + utils.last-slide-number
+ }
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ header: header,
+ footer: footer,
+ ),
+ )
+ touying-slide(self: self, ..args)
+})
+
+#let title-slide(..args) = touying-slide-wrapper(self => {
+ let info = self.info + args.named()
+ let body = {
+ set align(center + horizon)
+ block(
+ fill: self.colors.primary,
+ width: 80%,
+ inset: (y: 1em),
+ radius: 1em,
+ text(size: 2em, fill: self.colors.neutral-lightest, weight: "bold", info.title),
+ )
+ set text(fill: self.colors.neutral-darkest)
+ if info.author != none {
+ block(info.author)
+ }
+ if info.date != none {
+ block(utils.display-info-date(self))
+ }
+ if info.contact != none {
+ block(info.contact)
+ }
+ }
+ touying-slide(self: self, body)
+})
+
+#let new-section-slide(self: none, body) = touying-slide-wrapper(self => {
+ let main-body = {
+ set align(center + horizon)
+ set text(size: 2em, fill: self.colors.primary, weight: "bold", style: "italic")
+ utils.display-current-heading(level: 1)
+ }
+ touying-slide(self: self, main-body)
+})
+
+#let focus-slide(body) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ fill: self.colors.primary,
+ margin: 2em,
+ ),
+ )
+ set text(fill: self.colors.neutral-lightest, size: 2em)
+ touying-slide(self: self, align(horizon + center, body))
+})
+
+#let bamboo-theme(
+ aspect-ratio: "16-9",
+ footer: none,
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(
+ paper: "presentation-" + aspect-ratio,
+ margin: (top: 4em, bottom: 1.5em, x: 2em),
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ ),
+ config-methods(alert: utils.alert-with-primary-color),
+ config-colors(
+ primary: rgb("#5E8B65"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ ),
+ config-store(
+ title: none,
+ footer: footer,
+ ),
+ ..args,
+ )
+
+ body
+}
+
+
+// main.typ
+<<< #import "@preview/touying:0.7.3": *
+<<< #import "bamboo.typ": *
+
+#show: bamboo-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ ),
+)
+
+#title-slide()
+
+= First Section
+
+== First Slide
+
+A slide with a title and an *important* information.
+
+#focus-slide[
+ Focus on it!
+]
+```
+
+
+
+## Conclusion
+
+Congratulations! You've created a simple and elegant theme. Perhaps you may find that Touying introduces a wealth of concepts, making it initially challenging to grasp. This is normal, as Touying opts for functionality over simplicity. However, thanks to Touying's comprehensive and unified approach, you can easily extract commonalities between different themes and transfer your knowledge seamlessly. You can also save global variables, modify existing themes, or switch between themes effortlessly, showcasing the benefits of Touying's decoupling.
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Code Style
+
+## Simple Style
+
+If we just need to use it simply, we can directly input content under the title, just like writing a normal Typst document. The titles here serve to separate pages, and we can also normally use commands like `#pause` to achieve animation effects.
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
+
+And you can use an empty title `== <touying:hidden>` to create a new page, which is also helpful to clear the continued application of the previous title.
+
+If we need to maintain the current title and just want to add a new page, we can use `#pagebreak()`, or directly use `---` to split the page, the latter is parsed as `#pagebreak()` in Touying.
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+---
+
+Hello, Typst!
+```
+
+## Block Style
+
+Many times, using only the simple style cannot achieve all the functions we need. For more powerful functions and clearer structure, we can also use the block style in the form of `#slide[...]`.
+
+For example, the above example can be transformed into
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+#slide[
+ Hello, Touying!
+
+ #pause
+
+ Hello, Typst!
+]
+```
+
+And `#empty-slide[]` can create an empty Slide without a header and footer.
+
+There are many benefits to doing this:
+
+1. Many times, we need more than the default `#slide[...]`, we also need special `slide` functions like `#focus-slide[...]`;
+2. The `#slide[...]` function of different themes may have more parameters than the default, for example, the `#slide[...]` function of the metropolis theme will have an `align` parameter that can set the alignment;
+3. Only `slide` functions can use callback-style content blocks to use `#only` and `#uncover` functions to achieve complex animation effects.
+4. It can have a clearer structure, by identifying `#slide[...]` blocks, we can easily distinguish the specific pagination effects of slides.
+
+## Convention Over Configuration
+
+You may have noticed that when using the simple theme, using a first-level title automatically creates a section slide. This is because the simple theme registers a `config-common(slide-fn: slide, new-section-slide-fn: new-section-slide)` function, so Touying will call this function by default.
+
+If we do not want it to automatically create such a section slide, we can remove this method:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(new-section-slide-fn: none),
+)
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
+
+As you can see, this will only result in two pages, and the default section slide will disappear.
+
+Similarly, we can also register a new section slide:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(new-section-slide-fn: section => {
+ touying-slide-wrapper(self => {
+ touying-slide(
+ self: self,
+ {
+ set align(center + horizon)
+ set text(size: 2em, fill: self.colors.primary, style: "italic", weight: "bold")
+ utils.display-current-heading(level: 1)
+ },
+ )
+ })
+ }),
+)
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
\ No newline at end of file
--- /dev/null
+{
+ "label": "Dynamic Slides",
+ "position": 5,
+ "link": {
+ "type": "generated-index",
+ "description": "To create animations in PDF, we need to create multiple slightly different pages for the same slide. This allows animation by switching between these pages, and we refer to these pages as subslides."
+ }
+}
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# Complex Animations
+
+Thanks to the syntax provided by [Polylux](https://polylux.dev/book/dynamic/syntax.html), we can also use `only`, `uncover`, and `alternatives` in Touying.
+
+
+## Mark-Style Functions
+
+We can use mark-style functions, which are very convenient to use.
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+```
+
+However, this does not work in all cases, for example if you put `uncover` into the context expression, you will get an error.
+
+
+## Callback-Style Functions
+
+To overcome the limitations of layout functions mentioned earlier, Touying cleverly implements always-effective `only`, `uncover`, and `alternatives` using callback functions. Specifically, you need to introduce these three functions as follows:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+])
+```
+
+Notice that we no longer pass a content block but instead pass a callback function with a `self` parameter. Later, we extract `only`, `uncover`, and `alternatives` functions from `self` using:
+
+```typst
+#let (uncover, only, alternatives) = utils.methods(self)
+```
+
+We then call these functions in subsequent steps.
+
+Here's an interesting fact: the `self.subslide` of type int indicates the current subslide index, and in fact, the `only`, `uncover`, and `alternatives` functions rely on `self.subslide` to determine the current subslide index.
+
+:::warning[Warning]
+
+We manually specify the `repeat: 3` parameter, indicating the display of 3 subslides. We need to do this manually because Touying cannot infer how many subslides `only`, `uncover`, and `alternatives` should display when we use the bindings via utils.
+
+:::
+
+## only
+
+The `only` function means it "appears" only on selected subslides. If it doesn't appear, it completely disappears and doesn't occupy any space. In other words, `#only(index, body)` is either `body` or `none`.
+
+The index can be an int type or a str type like `"2-"` or `"2-3"`. For more usage, refer to [Polylux](https://polylux.dev/book/dynamic/complex.html).
+
+For more convenience we also support `auto`, which uses the current subslide position when `only` is encountered; `"h"` which does the same, but is a string, and derivations of that: `"h-"` and `"-h"`.
+Furthermore we allow the use of the inversion via `"!"`. Simply write `"!h"` or `"!2-4"` to get all but those subslides. Contrary to normal indices, the inversion does not increment subslide count.
+
+For how to use waypoints, please see the dedicated section on [waypoints](./waypoints.md).
+
+## uncover
+
+The `uncover` function means it "displays" only on selected subslides; otherwise, it will be covered by the `cover` function but still occupies the original space. In other words, `#uncover(index, body)` is either `body` or `cover(body)`.
+
+The index can be an int type or a str type like `"2-"` or `"2-3"`. For more usage, refer to [Polylux](https://polylux.dev/book/dynamic/complex.html). \
+But you can also use the other options you saw for `only` above.
+
+You may also have noticed that `#pause` actually uses the `cover` function, providing a more convenient syntax. In reality, their effects are almost identical.
+
+## alternatives
+
+The `alternatives` function displays a series of different content in different subslides. For example:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ #alternatives[Ann][Bob][Christopher]
+ likes
+ #alternatives[chocolate][strawberry][vanilla]
+ ice cream.
+])
+```
+
+As you can see, `alternatives` can automatically expand to the most suitable width and height, a capability that `only` and `uncover` lack. In fact, `alternatives` has other parameters, such as `start: 2`, `repeat-last: true`, and `position: center + horizon`. For more usage, refer to [Polylux](https://polylux.dev/book/dynamic/alternatives.html).
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# Cover Function
+
+As you already know, both `uncover` and `#pause` use the `cover` function to conceal content that is not visible. So, what exactly is the `cover` function here?
+
+## Default Cover Function: `hide`
+
+The `cover` function is a method stored in `s.methods.cover`, which is later used by `uncover` and `#pause`.
+
+The default `cover` function is the [hide](https://typst.app/docs/reference/layout/hide/) function. This function makes the internal content invisible without affecting the layout.
+
+## Updating the Cover Function
+
+In some cases, you might want to use your own `cover` function. In that case, you can set your own `cover` function using:
+
+```typst
+config-methods(cover: (self: none, body) => hide(body))
+```
+
+## hack: handle enum and list
+
+You will find that the existing cover function cannot hide the mark of enum and list, refer to [here](https://github.com/touying-typ/touying/issues/10), so you can hack:
+
+```typst
+config-methods(cover: (self: none, body) => box(scale(x: 0%, body)))
+```
+
+## Semi-Transparent Cover Function
+
+Touying supports a semi-transparent cover function, which can be enabled by adding:
+
+```typst
+config-methods(cover: utils.semi-transparent-cover.with(alpha: 85%))
+```
+
+You can adjust the transparency through the `alpha: ..` parameter.
+
+:::warning[Warning]
+
+Note that the `transparent-cover` here does not preserve text layout like `hide` does because it adds an extra layer of `box`, which may disrupt the original structure of the page.
+
+:::
+
+:::tip[Internals]
+
+The `utils.semi-transparent-cover` method is defined as:
+
+```typst
+#let semi-transparent-cover(self: none, constructor: rgb, alpha: 85%, body) = {
+ cover-with-rect(
+ fill: update-alpha(
+ constructor: constructor,
+ self.page.fill,
+ alpha,
+ ),
+ body,
+ )
+}
+```
+
+It creates a semi-transparent rectangular mask with the same color as the background to simulate the effect of transparent content. Here, `constructor: rgb` and `alpha: 85%` indicate the background color's construction function and transparency level, respectively.
+
+:::
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# Math Equation Animations
+
+Touying also provides a unique and highly useful feature—math equation animations, allowing you to conveniently use `pause` and `meanwhile` within math equations.
+
+## Simple Animation
+
+Let's start with an example:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ Touying equation with pause:
+
+ $
+ f(x) &= pause x^2 + 2x + 1 \
+ &= pause (x + 1)^2 \
+ $
+
+ #meanwhile
+
+ Touying equation is very simple.
+]
+```
+
+We use the `touying-equation` function to incorporate `pause` and `meanwhile` within the text of math equations (in fact, you can also use `#pause` or `#pause;`).
+
+As you would expect, the math equation is displayed step by step, making it suitable for presenters to demonstrate their math reasoning.
+
+## Complex Animation
+
+In fact, we can also use `only`, `uncover`, and `alternatives`:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ $
+ f(x) &= pause x^2 + 2x + uncover("3-", 1) \
+ &= pause (x + 1)^2 \
+ $
+])
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 6
+---
+
+# Handout Mode
+
+Handout mode collapses all animation subslides into a single page per logical slide, making it easy to produce a printable or distributable version of your presentation.
+
+## Enabling Handout Mode
+
+```typst
+config-common(handout: true)
+```
+
+Place this inside your theme setup:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(handout: true),
+)
+
+= Title
+
+== Animated Slide
+
+First item.
+
+#pause
+
+Second item (won't generate a separate page in handout mode).
+
+#pause
+
+Third item.
+```
+
+By default, handout mode keeps only the **last** subslide of each slide.
+
+## Choosing Which Subslide to Keep
+
+You can choose a specific subslide (or a set of subslides) to keep in handout output with `handout-subslides`:
+
+```typst
+// Keep only the first subslide (useful for "before" snapshots)
+config-common(handout: true, handout-subslides: 1)
+
+// Keep the first and last subslides
+config-common(handout: true, handout-subslides: (1, -1))
+
+// Keep a range expressed as a string (same syntax as `only`/`uncover`)
+config-common(handout: true, handout-subslides: "1-2")
+```
+
+## Handout-only Slides
+
+Use the `<touying:handout>` label to create slides that appear **only** in handout mode and are hidden during normal presentation:
+
+```typst
+== Extra Notes for Handout <touying:handout>
+
+This slide is included when `handout: true` but invisible otherwise.
+```
+
+## Workflow Tip
+
+A common workflow is to keep `handout: false` (the default) while presenting, then switch to `handout: true` when exporting a PDF to share with your audience:
+
+```typst
+// During presentation
+#show: my-theme.with(config-common(handout: false))
+
+// When building the handout PDF
+#show: my-theme.with(config-common(handout: true))
+```
--- /dev/null
+---
+sidebar_position: 5
+---
+
+# Other Animations
+
+Touying also provides `touying-reducer`, which allows all animations to work natively in CeTZ and Fletcher.
+
+## Simple Animations
+
+Here's an example:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: university-theme.with(aspect-ratio: "16-9")
+
+// cetz animation
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0,0), (5,5))
+
+ (pause,)
+
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+
+ (pause,)
+
+ line((0,0), (2.5, 2.5), name: "line")
+ })
+]
+
+// fletcher animation
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1,0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0,0), `reading`, radius: 2em),
+ edge((0,0), (0,0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1,0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2,0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0,0), (2,0), `close()`, "-|>", bend: -40deg),
+ )
+]
+```
+
+
+## `only`,`uncover` and `alternatives`
+
+In fact, we can also use `only`,`uncover` and even `alternatives` within CeTZ and Fletcher with the same syntax. Since CeTZ and Fletcher are generally position based the diagram will turn out to be the same, but under the hood the act differently. `only` drops the draw command, whereas `uncover` covers it.
+
+```typst
+//imports, bindings and theme
+
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0,0), (5,5))
+ (pause,)
+
+ rect((0,0), (1,1))
+
+ (uncover(3, {
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+ }),)
+
+ (only(3, line((0,0), (2.5, 2.5), name: "line") ),)
+ })
+]
+
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ spacing: 4em,
+ node((0, 0), [A], radius: 2em),
+ pause,
+ uncover("1-2", edge((0, 0), (1, 0), "-|>", stroke: blue)),
+ uncover("2-", node((1, 0), [B], radius: 2em)),
+ only(3, node((0, 1), [tmp], radius: 1em, fill: orange)),
+ )
+]
+```
+Note that commands like `effect` and `item-by-item` might not work as expected.
+
+## Callback-Style Bindings
+
+If you don't want to have to write the array syntax `(anim-cmd(), )` for CeTZ, you can redefine the commands you need via utils locally in the canvas. This way they output the format CeTZ understands natively. However, then you need to manually count your subslides via `repeat`!
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/cetz:0.5.0"
+#import themes.simple: *
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ Cetz in Touying in subslide #self.subslide:
+
+ #cetz.canvas({
+ import cetz.draw: *
+ let uncover = uncover.with(cover-fn: hide.with(bounds: true))
+
+ rect((0,0), (5,5))
+
+ uncover("2-3", {
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+ })
+
+ only(3, line((0,0), (2.5, 2.5), name: "line"))
+ })
+])
+```
+(This also works for Fletcher, but there should be no reason to use it really.)
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Simple Animations
+
+Touying provides two markers for simple animation effects: `#pause` and `#meanwhile`.
+
+## pause
+
+The purpose of `#pause` is straightforward – it separates the subsequent content into the next subslide. You can use multiple `#pause` to create multiple subslides. Here's a simple example:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ First #pause Second
+
+ #pause
+
+ Third
+]
+```
+
+This example will create three subslides, gradually revealing the content.
+
+As you can see, `#pause` can be used inline or on a separate line.
+
+## meanwhile
+
+In some cases, you may need to display additional content simultaneously with `#pause`. In such cases, you can use `#meanwhile`.
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ First
+
+ #pause
+
+ Second
+
+ #meanwhile
+
+ Third
+
+ #pause
+
+ Fourth
+]
+```
+
+This example will create only two subslides, with "First" and "Third" displayed simultaneously, and "Second" and "Fourth" displayed simultaneously.
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 7
+---
+
+# Waypoints
+
+Waypoints let you name positions in your slide's animation timeline and reference them by label instead of hard-coded subslide numbers. This makes animations easier to maintain — inserting a pause or item before a waypoint automatically shifts everything that follows. No more counting the subslides yourself.
+
+## Basic Usage
+
+Place a `#waypoint(<label>)` to mark a named position, then use the label in `#uncover` or `#only`:
+
+```typst
+#slide[
+ Base content.
+ #waypoint(<step-a>)
+ #uncover(<step-a>)[Uncovered from step-a.]
+ #waypoint(<step-b>)
+ #only(<step-b>)[Only during step-b.]
+]
+```
+
+Each advancing waypoint (the default) creates a new subslide. Here `<step-a>` fires on subslide 2 and `<step-b>` on subslide 3.
+
+## Implicit Waypoints
+
+When you pass a new label directly to `#uncover`, `#only`, or `#item-by-item`, an implicit waypoint is emitted automatically — no separate `#waypoint` call needed:
+
+```typst
+#slide[
+ First content.
+ #uncover(<reveal>)[Appears from here.]
+ #only(<final>)[Only on the last step.]
+]
+```
+
+The implicit waypoint is only registered once per label (the first occurrence wins), so multiple references to the same label share the same position.
+
+## item-by-item with Waypoints
+
+`#item-by-item` accepts a label as its `start` parameter. The items begin revealing from that waypoint's position:
+
+```typst
+#slide[
+ #item-by-item(start: <list>)[
+ - Alpha
+ - Beta
+ - Gamma
+ ]
+ #only(<done>)[All items revealed.]
+]
+```
+
+This produces 4 subslides: items appear on 2, 3, 4 (the implicit `<list>` waypoint advances to subslide 2), and `<done>` fires on subslide 5.
+
+> Note: Waypoints capture all subslides into their range until a new waypoint follows.
+
+## Non-advancing Waypoints
+
+By default, waypoints advance the subslide counter. Use `advance: false` on explicit waypoints to mark a position without creating a new subslide:
+
+```typst
+#slide[
+ #waypoint(<here>, advance: false)
+ Content at the current position.
+]
+```
+
+## Waypoint Markers
+
+For more control, use waypoint markers (`wp-m`) to reference specific parts of a waypoint's range:
+
+| Marker | Meaning |
+|---|---|
+| `from-wp(<label>)` | All subslides following the first subslide of the waypoint. |
+| `until-wp(<label>)` | All subslides until the last subslide of the waypoint's range. |
+| `get-first(<label>)` | The first subslide of the waypoint's range. |
+| `get-last(<label>)` | The last subslide of the waypoint's range. |
+| `prev-wp(<label>)` | The previous waypoint to the given one. |
+| `next-wp(<label>)` | The next waypoint to the given one. |
+| `not-wp(<label>)` | All subslides not in the waypoint's range. |
+
+```typst
+#slide[
+ #waypoint(<mid>)
+ #uncover(<mid>)[Visible during mid.]
+ #waypoint(<end>)
+ #uncover(from-wp(<mid>))[From mid onward.]
+ #only(prev-wp(<end>))[Only before end starts.]
+]
+```
+
+You may even combine waypoint markers to specify the exact behaviour you need:
+
+```typst
+#slide[
+ #waypoint(<mid>, advance:false)
+ #uncover(<mid>)[Visible during mid.]
+ #pause
+ Second mid.
+ #waypoint(<end>)
+ End.
+
+ #only(not-wp(get-first(<mid>)))[Soon finished.]
+]
+```
+
+
+## Complex Example
+As previously hinted, waypoints capture the range of subslides following them and you can reuse waypoints later to refer to a whole range.
+```typst
+#slide(composer: (1fr, 1fr))[
+ #item-by-item(start: <steps>)[
+ - Step one
+ - Step two
+ - Step three
+ ]
+ #pause
+ Some remark.
+ #uncover(<done>)[All done!]
+][
+ #alternatives(at: (<steps>, <done>))[
+ _Working through the steps..._
+ ][
+ _Complete!_
+ ]
+]
+```
+
+## Explicit Waypoint Starts
+You may even set explicit start values for waypoints, both subslide indexes and other waypoints are possible.
+
+```typst
+#slide(composer: (1fr, 1fr))[
+ #item-by-item(start: <steps>)[
+ - Step one
+ - Step two
+ - Step three
+ ]
+ #pause
+ Some remark.
+ #uncover(<done>, start: 4)[All done, even before the remark!]
+][
+ #waypoint(<parallel>, start: <done>)
+ Explaining stuff.
+ #pause
+ More explanation.
+]
+```
+
+
+## More Examples
+
+For a comprehensive set of examples covering all waypoint features — including callback-style slides, integration to CeTZ and Fletcher, `recall-subslide`, and edge cases — see [`examples/waypoints.typ`](https://github.com/touying-typ/touying/blob/main/examples/waypoints.typ).
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# Page Layout
+
+## Basic Concepts
+
+To create stylish slides using Typst, it's essential to understand Typst's page model correctly. If you're not concerned with customizing page styles, you can choose to skip this section. However, it's still recommended to go through it.
+
+Let's illustrate Typst's default page model through a specific example.
+
+```example
+#let container = rect.with(height: 100%, width: 100%, inset: 0pt)
+#let innerbox = rect.with(stroke: (dash: "dashed"))
+
+#set text(size: 30pt)
+#set page(
+ paper: "presentation-16-9",
+ header: container[#innerbox[Header]],
+ header-ascent: 30%,
+ footer: container[#innerbox[Footer]],
+ footer-descent: 30%,
+)
+
+#place(top + right)[Margin→]
+#container[
+ #container[
+ #innerbox[Content]
+ ]
+]
+```
+
+We need to distinguish the following concepts:
+
+1. **Model:** Typst has a model similar to the CSS Box Model, divided into Margin, Padding, and Content. However, padding is not a property of `set page(..)` but is obtained by manually adding `#pad(..)`.
+2. **Margin:** Margins are the edges of the page, divided into top, bottom, left, and right. They are the core of Typst's page model, and all other properties are influenced by margins, especially Header and Footer. Header and Footer are actually located within the Margin.
+4. **Header:** The Header is the content at the top of the page, divided into container and innerbox. We can observe that the edge of the header container and padding does not align but has some space in between, which is actually `header-ascent: 30%`, where the percentage is relative to the margin-top. Additionally, we notice that the header innerbox is actually located at the bottom left corner of the header container, meaning innerbox defaults to `#set align(left + bottom)`.
+5. **Footer:** The Footer is the content at the bottom of the page, similar to the Header but in the opposite direction.
+6. **Place:** The `place` function enables absolute positioning relative to the parent container without affecting other elements inside the parent container. It allows specifying `alignment`, `dx`, and `dy`, making it suitable for placing decorative elements like logos.
+
+Therefore, to apply Typst to create slides, we only need to set:
+
+```typst
+#set page(
+ margin: (x: 4em, y: 2em),
+ header: align(top)[Header],
+ footer: align(bottom)[Footer],
+ header-ascent: 0em,
+ footer-descent: 0em,
+)
+```
+
+However, we still need to address how the header occupies the entire page width. Here, we use negative padding to achieve this. For instance:
+
+```example
+#let container = rect.with(stroke: (dash: "dashed"), height: 100%, width: 100%, inset: 0pt)
+#let innerbox = rect.with(fill: rgb("#d0d0d0"))
+#let margin = (x: 4em, y: 2em)
+
+// negative padding for header and footer
+#let negative-padding = pad.with(x: -margin.x, y: 0em)
+
+#set text(size: 30pt)
+#set page(
+ paper: "presentation-16-9",
+ margin: margin,
+ header: negative-padding[#container[#align(top)[#innerbox(width: 100%)[Header]]]],
+ header-ascent: 0em,
+ footer: negative-padding[#container[#align(bottom)[#innerbox(width: 100%)[Footer]]]],
+ footer-descent: 0em,
+)
+
+#place(top + right)[↑Margin→]
+#container[
+ #container[
+ #innerbox[Content]
+ ]
+]
+```
+
+## Page Management
+
+In Typst, using the `set page(..)` command to modify page parameters results in the creation of a new page, rather than modifying the current one. Therefore, Touying opts to maintain a `self.page` member variable.
+
+For example, the previous example can be rewritten as:
+
+```typst
+#show: default-theme.with(
+ config-page(
+ margin: (x: 4em, y: 2em),
+ header: align(top)[Header],
+ footer: align(bottom)[Footer],
+ header-ascent: 0em,
+ footer-descent: 0em,
+ ),
+)
+```
+
+Touying will automatically detect the value of `margin.x` and determine whether to apply negative padding to the header if `config-common(zero-margin-header: true)` is set, which is equivalent to `self.zero-margin-header = true`.
+
+Similarly, if you are not satisfied with the style of the header or footer of a particular theme, you can also modify it through:
+
+```typst
+config-page(footer: [Custom Footer])
+```
+
+:::warning[Warning]
+
+Therefore, you should not use the `set page(..)` command yourself, as it will be reset by Touying.
+
+:::
+
+With this approach, we can also query the current page parameters in real-time using `self.page`, which is very useful for functions that need to obtain the page margins or the current page background color, such as `transparent-cover`. This is somewhat equivalent to context get rule, and in practice, it is more convenient to use.
+
+## Page Columnization
+
+If you need to divide a page into two or three columns, you can use the `composer` feature provided by the default `slide` function in Touying. The simplest example is as follows:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ First column.
+][
+ Second column.
+]
+```
+
+If you need to change the way columns are divided, you can modify the `composer` parameter of `slide`, where the default parameter is `cols.with(columns: auto, gutter: 1em)`. If we want the left column to take up the remaining width, we can use:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide(composer: (1fr, auto))[
+ First column.
+][
+ Second column.
+]
+```
+
+## Equalizing Column Heights with `lazy-v`
+
+When using multi-column layouts (via `cols` or a manual `grid`), columns with different amounts of content will have different heights. If you want to push some "footer" content (e.g. a label or caption) to the bottom of each column and have it align across all columns, or simply want all columns to match the tallest one's height, you can use `lazy-v` together with `lazy-layout`.
+
+### How It Works
+
+- **`lazy-v(1fr)`** — Place this between the main content and the footer content inside a block. It acts as a deferred vertical spacer that is invisible during height measurement.
+- **`lazy-layout`** — Wraps the multi-column layout. It first measures the natural height of all columns (ignoring `lazy-v` markers), then re-renders at that fixed height with the markers activated. This causes each column to stretch to match the tallest one, without the overall container expanding to fill the entire page.
+
+### Using `cols` (Recommended)
+
+`cols` enables `lazy-layout` by default, so you just need to add `lazy-v(1fr)` inside each block:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#cols[
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(10)
+ #lazy-v(1fr)
+ Bottom left.
+ ]
+][
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(20)
+ #lazy-v(1fr)
+ Bottom right.
+ ]
+]
+```
+Both columns will have the same height (matching the taller one), and "Bottom left." / "Bottom right." will be aligned at the bottom. The overall layout height equals the tallest column — it does **not** expand to fill the entire page.
+
+:::note[Note]
+
+This is different from using `v(1fr)` inside `#slide[][]`. The `slide` composer occupies the full page height, so `v(1fr)` works directly there. `lazy-v` is designed for standalone `cols` or `lazy-layout` calls where you want height equalization without full-page expansion.
+
+:::
+
+### Using a Manual Grid
+
+You can also wrap a `grid` with `lazy-layout` directly:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#lazy-layout(grid(
+ columns: (1fr, 1fr),
+ gutter: 1em,
+ block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(10)
+ #lazy-v(1fr)
+ Bottom left.
+ ],
+ block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(20)
+ #lazy-v(1fr)
+ Bottom right.
+ ],
+))
+```
+
+:::tip[Tip]
+
+If you don't need the height-equalizing behavior, pass `lazy-layout: false` to `cols` to opt out.
+
+:::
+
+## Preventing Content Overflow
+
+By default, when slide content exceeds the page height, Touying automatically overflows the excess content to the next page. This is reasonable in most cases, but in scenarios that require strict control over page mapping — such as agentic workflows where an agent needs to reason about slide boundaries — you may want to disable this behavior.
+
+Use `config-common(breakable: false)` to prevent content from overflowing:
+
+```typst
+// Prevent overflow, panic on overflow (default behavior when breakable: false)
+#show: simple-theme.with(
+ config-common(breakable: false),
+)
+
+// Prevent overflow and visually clip overflowing content
+#show: simple-theme.with(
+ config-common(breakable: false, clip: true),
+)
+
+// Prevent overflow, disable overflow detection (performance-first)
+#show: simple-theme.with(
+ config-common(breakable: false, detect-overflow: false),
+)
+```
+
+Related parameters:
+
+- **`clip`** (default `false`): When `true`, content that exceeds the slide height is visually truncated.
+- **`detect-overflow`** (default `true`): When `true`, a layout measurement is performed and `panic()` is called if the content height exceeds the available slide height, making it easy to catch overflow early. Set to `false` to avoid the extra layout overhead.
+
+:::note[Note]
+
+`clip` and `detect-overflow` only take effect when `breakable: false`.
+
+:::
+
+You can also dynamically switch these settings mid-presentation using `touying-set-config`:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme.with(config-common(breakable: false))
+== This slide's overflow will be clipped
+
+// Enable clipping for a specific slide
+#show: touying-set-config.with(config-common(clip: true))
+
+#lorem(500)
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 6
+---
+
+# Multi-File Architecture
+
+Touying features a syntax as concise as native Typst documents, along with numerous customizable configuration options, yet it still maintains real-time incremental compilation performance, making it suitable for writing large-scale slides.
+
+If you need to write a large set of slides, such as a course manual spanning tens or hundreds of pages, you can also try Touying's multi-file architecture.
+
+## Configuration and Content Separation
+
+A simple Touying multi-file architecture consists of three files: a global configuration file `globals.typ`, a main entry file `main.typ`, and a content file `content.typ` for storing the actual content.
+
+These three files are separated to allow both `main.typ` and `content.typ` to import `globals.typ` without causing circular references.
+
+`globals.typ` can be used to store some global custom functions and initialize Touying themes:
+
+```typst
+// globals.typ
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+// as well as some utility functions
+```
+
+`main.typ`, as the main entry point of the project, applies show rules by importing `globals.typ` and includes `content.typ` using `#include`:
+
+```typst
+// main.typ
+#import "/globals.typ": *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#include "content.typ"
+```
+
+`content.typ` is where you write the actual content:
+
+```typst
+// content.typ
+#import "/globals.typ": *
+
+= The Section
+
+== Slide Title
+
+Hello, Touying!
+
+#focus-slide[
+ Focus on me.
+]
+```
+
+## Multiple Sections
+
+Implementing multiple sections is also straightforward. You only need to create a `sections` directory and move the `content.typ` file to the `sections.typ` directory, for example:
+
+```typst
+// main.typ
+#import "/globals.typ": *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#include "sections/content.typ"
+// #include "sections/another-section.typ"
+```
+
+And
+
+```typst
+// sections/content.typ
+#import "/globals.typ": *
+
+= The Section
+
+== Slide Title
+
+Hello, Touying!
+
+#focus-slide[
+ Focus on me.
+]
+```
+
+Now, you have learned how to use Touying to achieve a multi-file architecture for large-scale slides.
\ No newline at end of file
--- /dev/null
+{
+ "label": "Progress & Sections",
+ "position": 7,
+ "link": {
+ "type": "generated-index",
+ "description": "Manage and display progress in Touying, including slide counters, section tracking, and appendix."
+ }
+}
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Slide Counters and Progress
+
+Touying provides a set of counters and utilities for tracking and displaying presentation progress.
+
+## Slide Counter
+
+`utils.slide-counter` is the primary Typst counter that increments on every slide.
+
+```typst
+// Display the current slide number
+#context utils.slide-counter.display()
+```
+
+Use it in a custom footer:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ footer: context [Slide #utils.slide-counter.display()],
+ ),
+)
+
+= Section
+
+== First Slide
+
+Content here.
+
+== Second Slide
+
+More content.
+```
+
+## Total Slide Number
+
+`utils.last-slide-number` holds the number of the last slide **before the appendix**. This is what you typically want to show as the denominator in a "slide X of Y" footer:
+
+```typst
+#context utils.slide-counter.display() + " / " + utils.last-slide-number
+```
+
+## Progress Bar
+
+`utils.touying-progress` provides a ratio (0.0–1.0) representing how far through the presentation you are:
+
+```typst
+#utils.touying-progress(ratio => {
+ // ratio is a float between 0.0 and 1.0
+ box(width: ratio * 100%, height: 4pt, fill: primary)
+})
+```
+
+This is how the metropolis and aqua themes implement their progress bars.
+
+## Appendix
+
+The `appendix` show rule stops the slide counter so that appendix slides do not change the total shown in the footer:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Main Section
+
+== Introduction
+
+The slide count increments normally here.
+
+== Second Slide
+
+Still counting.
+
+#show: appendix
+
+= Appendix
+
+== Backup Slide
+
+The footer still shows the count from the last main slide.
+```
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# Section Utilities
+
+Touying injects invisible headings into each slide so that you can query the current section at any time using Typst's `query()` function.
+
+## Displaying the Current Heading
+
+`utils.display-current-heading(level: N)` returns the text of the most recent heading at the given level. It is used by most themes to populate the header:
+
+```typst
+// Show the current section (level 1) in the header
+utils.display-current-heading(level: 1)
+
+// Show the current subsection (level 2)
+utils.display-current-heading(level: 2)
+```
+
+`utils.display-current-short-heading(level: N)` is a shorter variant that strips numbering:
+
+```typst
+utils.display-current-short-heading(level: 2)
+```
+
+## Custom Header with Section Name
+
+You can use these utilities in a custom header:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ header: [
+ #text(gray, utils.display-current-heading(level: 2))
+ #h(1fr)
+ #context utils.slide-counter.display()
+ ],
+ ),
+)
+
+= My Section
+
+== First Slide
+
+Header shows "First Slide" on the right side.
+
+== Second Slide
+
+Header updates automatically.
+```
+
+## Progressive Outlines
+
+Touying has a couple of progressive outline utilities. The easiest one is the following.
+
+### Progressive Outline (Default)
+
+[`components.progressive-outline()`](https://touying-typ.github.io/docs/reference/components/progressive-outline) renders an outline that highlights the current section and grays out the rest — a common pattern in themed presentations:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+
+#show: dewdrop-theme.with(aspect-ratio: "16-9")
+
+= Introduction
+
+== Overview <touying:hidden>
+
+#components.progressive-outline()
+
+= Background
+
+== Slide
+
+Content.
+```
+
+[`components.adaptive-columns(outline(...))`](https://touying-typ.github.io/docs/reference/components/adaptive-columns) is another variant that wraps a standard [`outline()`](https://typst.app/docs/reference/model/outline/) in an appropriate number of columns so it fits on one page.
+
+### Custom Progressive Outline
+
+[`components.custom-progressive-outline()`](https://touying-typ.github.io/docs/reference/components/custom-progressive-outline) allows you to specify all sorts of styling rules for the progressive outline. This makes it much more versatile, but you need to specify everything yourself.
+
+```example
+#import "@preview/touying:0.7.0": *
+#import themes.dewdrop: *
+
+#show: dewdrop-theme.with(aspect-ratio: "16-9")
+
+= Introduction
+
+== Overview <touying:hidden>
+
+#components.custom-progressive-outline(
+ level: 1,
+ show-past: (true, false),
+ show-future: (true, false),
+ show-current: (true, true, false),
+ vspace: (.5em, .0em),
+ numbering: ("1.1",),
+ numbered: (true,true),
+ title: none,
+)
+
+= Background
+
+== Slide
+
+Content.
+```
+
+Notice that we had to specify everything ourselves, there are no pretty defaults. An some parameters are automatically repeated, while others are not. If you don't like this you can also just adapt the outline entries directly with a `set` rule. For this purpose we provide you with a helper to get the current section context.
+
+### Section Relationship Helper
+
+The utility [`utils.section-relationship()`](https://touying-typ.github.io/docs/reference/utils/section-relationship) allows you to get the relationship of the current section you are in to some given outline entries. It returns an integer that can be of of (-2, -1, 0, 1, 2).
+
+Negative numbers are headings declared earlier in the document, positive values are headings declared later in the document. Only the current heading **and** its children have relationship `0`.
+
+The values -1 and 1 are reserved for other headings under the same top-level heading that the current section falls under. Together with the actual `outline.entry.level` this should be enough to construct any outline you like.
+
+You can e.g. use like so
+```example
+>>>#import "@preview/touying:0.7.3": *
+>>>#import themes.simple: *
+>>>#show: simple-theme
+>>>#set heading(numbering: "1.1")
+
+= Start
+== Start Sub
+#lorem(5)
+= My content
+== My heading
+#lorem(5)
+---
+#{// displays all top levels and all levels of the current top-level normally,
+ // with future siblings and other top levels semi-transparent
+ // the current entry bold and all others red.
+
+ show outline.entry: it => {
+ let relationship = utils.section-relationship(it)
+ let current = utils.current-heading()
+ let alpha = if relationship == -2 or relationship > 0 { 40% } else { 100% }
+ let weight = if relationship == 0 and current.level == it.level {
+ "bold"
+ } else { "regular" }
+ if it.level > 1 and calc.abs(relationship) > 1 {
+ text(fill: red, it) //normally you put `none` here.
+ } else {
+ text(fill: utils.update-alpha(text.fill, alpha), weight: weight, it)
+ }
+ }
+ outline(title: none)
+}
+---
+=== Subsubheading
+#lorem(3)
+
+== Another heading
+#lorem(5)
+
+= Next Top Level
+
+== Subsection
+#lorem(5)
+```
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# Sections and Subsections
+
+## Structure
+
+Like Beamer, Touying also has the concept of sections and subsections.
+
+Generally, first-level, second-level, and third-level headings correspond to sections, subsections, and subsubsections, respectively, such as in the dewdrop theme.
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+
+#show: dewdrop-theme.with(aspect-ratio: "16-9")
+
+= Section
+
+== Subsection
+
+=== Title
+
+Hello, Touying!
+```
+
+However, there are many times when we do not need subsections, so we also use first-level and second-level headings to correspond to sections and titles, respectively, such as in the university theme.
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+#show: university-theme.with(aspect-ratio: "16-9")
+
+= Section
+
+== Title
+
+Hello, Touying!
+```
+
+In fact, we can control this behavior through the `slide-level` parameter of the `config-common` function. `slide-level` represents the complexity of the nesting structure, starting from 0. For example, `#show: university-theme.with(config-common(slide-level: 2))` is equivalent to both `section` and `subsection` creating new slides; while `#show: university-theme.with(config-common(slide-level: 3))` is equivalent to `section`, `subsection`, and `subsubsection` all creating new slides.
+
+## Numbering
+
+To add numbering to sections and subsections, we simply use
+
+```typst
+#set heading(numbering: "1.1")
+#show heading.where(level: 1): set heading(numbering: "1.")
+```
+
+This sets the default numbering to `1.1`, and the section corresponds to the numbering `1.`.
+
+## Table of Contents
+
+Displaying a table of contents in Touying is straightforward:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#import "@preview/numbly:0.1.0": numbly
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Section
+
+== Subsection
+
+#components.adaptive-columns(outline(indent: 1em))
+```
+
+The `outline(indent: 1em)` is a native Typst function for the table of contents. The `#components.adaptive-columns()` function ensures that the table of contents occupies only one page, adapting by setting `#columns(1, body)` or `#columns(2, body)`, and so on.
+
+If you need a `outline` function that can display the current progress, you might consider using `#components.progressive-outline()` or `#components.custom-progressive-outline()`, as seen in the dewdrop theme. Or write your own by manipulating the `outline.entry` elements, for certain effects you may want to use `#utils.section-relationship`.
+
+## Special Heading Labels
+
+Touying recognises special labels on headings to control slide behavior:
+
+| Label | Effect |
+|-------|--------|
+| `<touying:hidden>` | The slide is not rendered at all (content and page are suppressed). |
+| `<touying:skip>` | The heading does not create a new-section slide. |
+| `<touying:unnumbered>` | The slide is not counted in the slide counter. |
+| `<touying:unoutlined>` | The heading is excluded from the `outline()`. |
+| `<touying:unbookmarked>` | No PDF bookmark is generated for this heading. |
+| `<touying:handout>` | The slide is shown only in handout mode. |
+
+Example — a hidden outline slide that does not appear in the final PDF:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#import "@preview/numbly:0.1.0": numbly
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= First Section
+
+== Slide One
+
+Content.
+```
+
+## Appendix
+
+The `appendix` function stops the slide counter so appendix slides do not affect the total count displayed in the footer.
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Main Section
+
+== Introduction
+
+Main content here. Check the slide number in the footer.
+
+#show: appendix
+
+= Appendix
+
+== Appendix Slide
+
+The slide number is frozen at the last main-section slide.
+```
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# Settings and Config
+
+## Global Styles
+
+For Touying, global styles refer to set rules or show rules that need to be applied everywhere, such as `#set text(size: 20pt)`.
+
+Themes in Touying encapsulate some of their own global styles, which are placed in `#self.methods.init`. For example, the simple theme encapsulates:
+
+```typst
+config-methods(
+ init: (self: none, body) => {
+ set text(fill: self.colors.neutral-darkest, size: 25pt)
+ show footnote.entry: set text(size: .6em)
+ show strong: self.methods.alert.with(self: self)
+ show heading.where(level: self.slide-level + 1): set text(1.4em)
+
+ body
+ },
+)
+```
+
+If you are not a theme creator but simply want to add some of your own global styles to your slides, you can easily place them before or after `#show: xxx-theme.with()`. For example, the metropolis theme recommends that you add the following global styles yourself:
+
+```typst
+#set text(font: "Fira Sans", weight: "light", size: 20pt)
+#show math.equation: set text(font: "Fira Math")
+#set strong(delta: 100)
+#set par(justify: true)
+```
+
+## Global Information
+
+Like Beamer, Touying helps you better maintain global information through a unified API design, allowing you to easily switch between different themes. Global information is a typical example of this.
+
+You can set the title, subtitle, author, date, institution, contact and logo information of your slides with:
+
+```typc
+config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: [logo.png],
+ extra: (supervisor:[Supervisor],),
+)
+```
+
+You can even pass extra information, to maintain presentation information not covered by the other attributes.
+
+Later on, you can access them through `self.info`.
+
+This information is generally used in the theme's `title-slide`, `header`, and `footer`, such as `#show: metropolis-theme.with(aspect-ratio: "16-9", footer: self => self.info.institution)`.
+
+The `date` can accept `datetime` format and `content` format, and the date display format of the `datetime` format can be changed with:
+
+```typc
+config-common(datetime-format: "[year]-[month]-[day]")
+```
+
+## Preamble
+
+The `config-common(preamble: ...)` option lets you run setup code on every slide without repeating it manually. This is useful when integrating packages like `codly`:
+
+```typst
+#show: simple-theme.with(
+ config-common(preamble: {
+ codly(languages: codly-languages)
+ }),
+)
+```
+However you may also set this locally for individual slides, see below.
+
+## Show-Rule Config Overrides
+
+You can override any configuration for all following slides and the current one, using `#show: touying-set-config.with(...)`, just like you would write a `show`/`set`-rule normally.
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+== Normal Slide
+
+This slide uses the default settings.
+
+
+
+== Blue Background Slide
+#show: touying-set-config.with(config-page(fill: blue.lighten(80%)))
+This slide has a blue background applied via `touying-set-config`.
+
+== Red Accent Slide
+#show: touying-set-config.with(config-colors(primary: red))
+This slide uses a red primary color, e.g. in `#alert` boxes.
+
+#alert[This is an alert box with red accent color.]
+
+== Changed Cover
+#show: touying-set-config.with(config-methods(
+ cover: utils.semi-transparent-cover,
+))
+Initial Content.
+
+#pause
+
+Content that appears with a semi-transparent cover effect.
+```
+
+## Local Config Overrides
+If you want to affect only one specific slide, you can set the config locally via `#slide(config: ...)[...]`.
+```example
+>>> #import "../lib.typ": *
+>>> #import themes.simple: *
+
+>>> #show: simple-theme.with(aspect-ratio: "16-9")
+== Local Config
+#slide(config:config-page(fill: purple.lighten(90%)))[
+Only this slide has a light purple background, but the next slide goes back being light blue.
+]
+```
+
+## Deferred Config Show Rules
+You may even defer a config change to the beginning of the next slide.
+This is also how `show: appendix` works, but also useful for setting a custom preamble or similar that affects not just the slide's content. (Note that config-common has no effect, you can also write your config dict without it.)
+
+```example
+>>> #import "../lib.typ": *
+>>> #import themes.simple: *
+
+>>> #show: simple-theme.with(aspect-ratio: "16-9")
+== Content Slide
+Some content.
+#show: touying-set-config.with(defer:true, config-common(appendix:true))
+// you can just write `show: appendix`
+== Appendix
+Page counter does no longer increase.
+#show: touying-set-config.with(defer:true, (preamble:{codly(languages: codly-languages)}))
+== Deferred Config Change
+Now we have codly available.
+```
+
+## Frozen Counters
+
+When using animation, figure and theorem counters inside a single slide keep advancing per subslide by default. To freeze a counter (so it does not change between subslides), use:
+
+```typst
+config-common(frozen-counters: (figure.where(kind: image),))
+```
+
+This is especially useful when working with the [Theorion](../integration/theorion.md) package:
+
+```typst
+config-common(frozen-counters: (theorem-counter,))
+```
+
+
+## Accessing Config Information
+
+You can use `touying-get-config` to access the stored config for a slide. This will be the global config combined with any overrides you made for that slide.
+
+Note that it is evaluated at `context` time and inserted into the document flow where you request it, thus it is only available as content.
+
+### Querying the Entire Config
+
+Call `touying-get-config()` without arguments to get the full config dictionary. You can then access nested values using normal dictionary syntax:
+
+```typst
+#touying-get-config().info.author
+
+#touying-get-config().common.handout
+```
+
+Since `common` fields are registered at the top level, you can access them directly:
+
+```typst
+#touying-get-config().handout // same as .common.handout
+```
+
+### Querying by Key
+
+Pass a dot-separated string key to retrieve a specific sub-config or value directly:
+
+```typst
+#touying-get-config("info.author")
+
+#touying-get-config("info") // returns the entire info sub-dict
+```
+
+### Default Values
+
+If the key does not exist, `touying-get-config` will panic by default. To provide a fallback value instead, use the `default` parameter:
+
+```typst
+#touying-get-config("random.dict.value", default: "default value")
+```
+
+### Accessing Custom Config
+
+If you set custom keys via `touying-set-config`, they become available immediately after the `show` rule:
+
+```typst
+#show: touying-set-config.with((random: (dict: (value: 123))))
+
+#touying-get-config("random.dict.value") // displays "123"
+```
+
+:::warning[Warning]
+
+When accessing custom config, you must use the string key form (`touying-get-config("random.dict.value")`) rather than chaining dictionary access (`touying-get-config("random.dict").value`), because the latter attempts to access `.value` on a content element, which will fail.
+
+:::
+
--- /dev/null
+{
+ "label": "Utilities",
+ "position": 8,
+ "link": {
+ "type": "generated-index",
+ "description": "Convenient utility functions provided by Touying."
+ }
+}
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Fit to Height / Width
+
+Thanks to [ntjess](https://github.com/ntjess) for the code.
+
+## Fit to Height
+
+If you need to make an image fill the remaining slide height, you can try the `fit-to-height` function:
+
+```typst
+#utils.fit-to-height(1fr)[BIG]
+```
+
+Function definition:
+
+```typst
+#let fit-to-height(
+ width: none, prescale-width: none, grow: true, shrink: true, height, body
+) = { .. }
+```
+
+Parameters:
+
+- `width`: If specified, this will determine the width of the content after scaling. So, if you want the scaled content to fill half of the slide width, you can use `width: 50%`.
+- `prescale-width`: This parameter allows you to make Typst's layout assume that the given content is to be laid out in a container of a certain width before scaling. For example, you can use `prescale-width: 200%` assuming the slide's width is twice the original.
+- `grow`: Whether it can grow, default is `true`.
+- `shrink`: Whether it can shrink, default is `true`.
+- `height`: The specified height.
+- `body`: The specific content.
+
+## Fit to Width
+
+If you need to limit the title width to exactly fill the slide width, you can try the `fit-to-width` function:
+
+```typst
+#utils.fit-to-width(1fr)[#lorem(20)]
+```
+
+Function definition:
+
+```typst
+#let fit-to-width(grow: true, shrink: true, width, body) = { .. }
+```
+
+Parameters:
+
+- `grow`: Whether it can grow, default is `true`.
+- `shrink`: Whether it can shrink, default is `true`.
+- `width`: The specified width.
+- `body`: The specific content.
+## Practical Example: Fitting a Table to the Slide
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ #utils.fit-to-height(1fr)[
+ #table(
+ columns: (1fr, 1fr, 1fr),
+ [A], [B], [C],
+ [1], [2], [3],
+ [4], [5], [6],
+ )
+ ]
+]
+```
+
+## Fitting a Heading to Full Width
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ #utils.fit-to-width(1fr)[
+ #text(weight: "bold")[A Very Long Presentation Title That Should Fill the Entire Slide Width]
+ ]
+]
+```
--- /dev/null
+---
+sidebar_position: 8
+---
+
+# 更新日志
+
+## v0.7.3
+
+### Minor Breaking Changes
+
+- **feat!: always attach `#speaker-note[]` to the previous slide & default `receive-body-for-new-*-slide-fn` to `false`** ([#354](https://github.com/touying-typ/touying/pull/354))
+ - `#speaker-note[]` now always attaches to the **slide above it**, regardless of how that slide was created (explicit slide calls, heading-triggered section slides, or normal content slides). This eliminates the common pitfall where a `#speaker-note[]` placed after a slide would silently create an unwanted empty "ghost" slide.
+ - `receive-body-for-new-section-slide-fn` and its variants are now **defaulted to `false`** (previously `true`).
+
+### Migration Guide
+
+If you relied on content after `= Section` headings being absorbed into the section slide body, explicitly set `receive-body-for-new-section-slide-fn: true` in your `config-common(...)`.
+
+### Features
+
+- feat: `item-by-item-fn` and presets for it ([#347](https://github.com/touying-typ/touying/pull/347))
+- feat: improved `custom-progressive-outline` and new `section-relationship` and some other things ([#345](https://github.com/touying-typ/touying/pull/345))
+- feat: better lazy-layout for mixed layouts ([#355](https://github.com/touying-typ/touying/pull/355))
+- feat: add `cols` as alias of `side-by-side` and export some components `cols`, `lazy-xxx` to outside ([#356](https://github.com/touying-typ/touying/pull/356))
+- theme(metropolis): add outline-slide for metropolis ([#349](https://github.com/touying-typ/touying/pull/349))
+- feat: add warning for empty slide content height detection
+
+### Documentation
+
+- docs: add multiple columns example and improve docs structure
+
+
+## v0.7.1
+
+### Features
+
+- feat(agents): `breakable` and `clip` options to avoid slide overflow ([#336](https://github.com/touying-typ/touying/pull/336))
+- feat(components): add `lazy-v` (`lazy-h`) and `lazy-layout` for equalizing multi-column (-row) block heights (widths) ([#339](https://github.com/touying-typ/touying/pull/339))
+- feat: additional `contact` and `extra` field in `config-info` ([#342](https://github.com/touying-typ/touying/pull/342))
+- feat: `touying-get-config` function ([#333](https://github.com/touying-typ/touying/pull/333))
+
+### Fixes
+
+- fix: fix `fit-to-height` and `size-to-pt` and allow text reflow ([#332](https://github.com/touying-typ/touying/pull/332))
+- fix: fix waypoint markers ([#341](https://github.com/touying-typ/touying/pull/341))
+
+
+
+## v0.7.0
+
+### Features
+
+- **major feature:** a named waypoint feature ([#298](https://github.com/touying-typ/touying/pull/298))
+- feat(waypoint): start param and Waypoints in handout-subslides ([#304](https://github.com/touying-typ/touying/pull/304))
+- feat: auto, "h"-here string and inverse function for string subslide-numbers and waypoints ([#301](https://github.com/touying-typ/touying/pull/301))
+- feat: implicitly allow fn-wrapper based animation functions via reducer ([#300](https://github.com/touying-typ/touying/pull/300))
+
+### Fixes
+
+- fix: fix cover-with-rect breaking long lines of text when partially hidden and fallback functions for color/alpha cover ([#328](https://github.com/touying-typ/touying/pull/328))
+- fix: using explicit numbering in display-current-heading when style=auto ([#329](https://github.com/touying-typ/touying/pull/329))
+- fix: fix ghost slides with show rules. Fix proper consistent handling of show rules and defer keyword ([#317](https://github.com/touying-typ/touying/pull/317))
+- fix: alert not delayed ([#316](https://github.com/touying-typ/touying/pull/316))
+- fix: remove redundant nested text call ([#324](https://github.com/touying-typ/touying/pull/324))
+- fix: function alternatives-match takes into account parameter stretch ([#320](https://github.com/touying-typ/touying/pull/320))
+- fix: correctly handle page margin merge/precedence ([#322](https://github.com/touying-typ/touying/pull/322))
+- fix: fix cover spacing issues surrounding lists ([#303](https://github.com/touying-typ/touying/pull/303))
+- fix: correctly parses negative subslide indices (ints, arrays) for handout-subslides ([#307](https://github.com/touying-typ/touying/pull/307))
+- fix: slide function does not update via scoped import ([#310](https://github.com/touying-typ/touying/pull/310))
+
+Thanks for the contributions from [@zral0kh](https://github.com/zral0kh), [@Andrew15-5](https://github.com/Andrew15-5), [@navdeeprana](https://github.com/navdeeprana), and [@Cemoixerestre](https://github.com/Cemoixerestre).
+
+
+## v0.6.3
+
+A major bugfix release, fixing many long-standing bugs and introducing many practical features.
+
+### Features
+
+- **feat: add `#jump(n, relative: bool)` as unified animation control; redefine `#pause`/`#meanwhile` as sugar**
+- feat: add `#handout-only` for inline content and `<touying:handout>` label for handout-exclusive slides ([#286](https://github.com/touying-typ/touying/pull/286))
+- feat: add `handout-subslides` to control which subslides appear in handout mode ([#288](https://github.com/touying-typ/touying/pull/288))
+- feat: add `#touying-raw` for animated code block reveals ([#283](https://github.com/touying-typ/touying/pull/283))
+- feat: add full-screen speaker notes mode with slide thumbnail (`show-only-notes`) ([#281](https://github.com/touying-typ/touying/pull/281))
+- feat: support arbitrary aspect ratios (e.g. 16-10) across all themes and speaker-note second screen ([#280](https://github.com/touying-typ/touying/pull/280))
+- feat: add `#item-by-item` animation for list, enum, and terms ([#278](https://github.com/touying-typ/touying/pull/278))
+- feat(recall): add subslide parameter to `#touying-recall` ([#285](https://github.com/touying-typ/touying/pull/285))
+- feat: add `default-composer` to config-common for global slide layout configuration ([#284](https://github.com/touying-typ/touying/pull/284))
+- feat: add `cover-fn` parameter to `uncover` for external package integration (e.g. Fletcher) ([#267](https://github.com/touying-typ/touying/pull/267))
+- feat: minislides can be displayed inline ([#228](https://github.com/touying-typ/touying/pull/228))
+- theme: improve appearance of long author lists in university and stargazer theme ([#242](https://github.com/touying-typ/touying/pull/242))
+- theme(simple): make simple-theme respect color configuration for deco-format ([#252](https://github.com/touying-typ/touying/pull/252))
+- theme(aqua,stargazer): add extra parameter to title-slide ([#291](https://github.com/touying-typ/touying/pull/291))
+
+### Fixes
+
+- fix: prevent ghost-slide blank pages from `touying-set-config` anchor regression ([#289](https://github.com/touying-typ/touying/pull/289))
+- fix: styled content on first slide no longer creates extra slides ([#287](https://github.com/touying-typ/touying/pull/287))
+- fix: remove unoutlined headings from navigation
+- fix: fix `#meanwhile` being ignored inside grid cells, boxes, and other containers ([#274](https://github.com/touying-typ/touying/pull/274))
+- fix: fix `config: parameter` silently ignored across all themes ([#273](https://github.com/touying-typ/touying/pull/273))
+- fix: fix slides after `#show`/`#set` rules not rendering subsequent slides ([#268](https://github.com/touying-typ/touying/pull/268))
+- fix: fix title page PDF page label causing pdfpc presenter notes mismatch ([#277](https://github.com/touying-typ/touying/pull/277))
+- fix: fix duplicate label error for labeled footnotes with `#pause` animations ([#275](https://github.com/touying-typ/touying/pull/275))
+- fix: fix `#pause` inside `#speaker-note` body (nested list items) ([#282](https://github.com/touying-typ/touying/pull/282))
+- theme(dewdrop): fix body content under level-1 heading was silently dropped ([#279](https://github.com/touying-typ/touying/pull/279))
+- theme(stargazer): update stargazer theme margins and fix [#259](https://github.com/touying-typ/touying/pull/259)
+
+### Documentation
+
+- **docs(BIG CHANGE): refactor docs website and add references page**
+- docs: reduce README noise, improve first impression ([#297](https://github.com/touying-typ/touying/pull/297))
+- docs: restructure docs + add docs-preview CI for PRs ([#296](https://github.com/touying-typ/touying/pull/296))
+- docs: comprehensive docstring improvements across all source files ([#294](https://github.com/touying-typ/touying/pull/294))
+
+### Theme Migration Guide
+
+**For theme developers upgrading to v0.6.3:**
+
+1. **Move `config` to the last position in `utils.merge-dicts`** to allow user overrides:
+ ```typst
+ // Before
+ self = utils.merge-dicts(self, config, config-page(...))
+
+ // After
+ self = utils.merge-dicts(self, config-page(...), config)
+ ```
+
+2. **Replace `paper` with `utils.page-args-from-aspect-ratio`** to support arbitrary aspect ratios:
+ ```typst
+ // Before
+ config-page(paper: "presentation-" + aspect-ratio, ...)
+
+ // After
+ config-page(..utils.page-args-from-aspect-ratio(aspect-ratio), ...)
+ ```
+
+
+## v0.6.2
+
+### Features
+
+- feat: allow customisation of `components.checkerboard` (#161)
+
+### Fixes
+
+- fix: support ratio and relative margins for full-width headers (#256)
+- fix: fix `magic.bibliography-as-footnote` in Typst 0.14 (#249)
+- fix: theorion package is broken with Typst 0.14.0 (#237)
+- fix: update `components.typ` and pass named arguments to grid (#207)
+- fix: fix `#meanwhile` in cetz (#205)
+- fix: documentation contains unclosed raw text error (#187)
+- fix: use correct circle symbol (#171)
+- fix: use regex to override colors of equations (#167)
+- fix: `show-hide-set-list-marker-none` with full enum (#157)
+- fix: remove dump and label-it function for better cache
+
+### Miscellaneous
+
+- docs: update README, bump versions of deps, and fix comment docs
+- ci: add more tests, bump versions of `tytanic`, and update typstyle workflow (#221, #261)
+
+## v0.6.1
+
+Added support for the [theorion](https://github.com/OrangeX4/typst-theorion) package, and used it as the default math theorem environment.
+
+## v0.6.0
+
+It's not a big update, but it's the first touying release since typst 0.13 was released.
+
+### Features
+
+- feat: add auto style for display-current-heading.
+ - For users, you can use `show heading: set text(blue)` to change color for heading in some themes like `dewdrop`.
+ - For theme creator, you can use syntax like `utils.display-current-heading(level: 1, style: auto)` to achieve the same result.
+- feat: apply config-info information to `set document`.
+- feat: set `stretch: false` by default for `alternatives` functions. This is **a minor breaking change**, but I think it would be more intuitive: no auto empty space.
+
+### Fixes
+
+- fix: fix error with uncover using semi-transparent-cover
+- fix: fix type string comparison https://github.com/touying-typ/touying/pull/153
+- fix: fix horizontal-line bug in typst 0.13.0
+- refactor: fix display-current-short-heading
+
+
+## v0.5.4 & v0.5.5
+
+### Features
+
+- docs: improve param documentation and we have better hints for tinymist https://github.com/touying-typ/touying/pull/98
+- feat: fake frozon states support for `heading` https://github.com/touying-typ/touying/pull/124
+- feat: add alpha-changing-cover and color-changing-cover https://github.com/touying-typ/touying/pull/129
+- feat: add effect function https://github.com/touying-typ/touying/issues/111
+ - Example: `#effect(text.with(fill: red), "2-")[Something]` will display `[Something]` if the current slide is 2 or later.
+- feat: add argument `config: (..)` for `xxx-slide` functions
+- feat: add `align` argument for university theme
+
+### Fixes
+
+- fix: also hide enum numbers with show-hide-set-list-marker-none https://github.com/touying-typ/touying/pull/114
+- fix: fixed progress bar not to break apart when global figure gutter is set nonzero https://github.com/touying-typ/touying/pull/120
+- fix: fixed frozen-counters bug with multiple #pause commands https://github.com/touying-typ/touying/pull/124
+- fix: fixed incorrect page num when draft is true https://github.com/touying-typ/touying/pull/125
+- fix: fix behaviors of fit-to-height and fit-to-width partially https://github.com/touying-typ/touying/pull/131
+- fix: duplicated footnotes in headings https://github.com/touying-typ/touying/pull/132
+- fix: do not hardcode page sizes https://github.com/touying-typ/touying/pull/134
+- fix: add default numbering for page https://github.com/touying-typ/touying/issues/100
+- refactor: move show-strong-with-alert to per-slide level https://github.com/touying-typ/touying/issues/123
+- refactor: remove unnecessary `config-page(fill: ...)`
+- theme(metropolis): fix color of title page and fix https://github.com/touying-typ/touying/issues/103
+- theme(metropolis): fixed metropolis slide's header to return content if title is specified https://github.com/touying-typ/touying/pull/126
+- theme(metropolis): respect colors dict in metropolis theme https://github.com/touying-typ/touying/pull/133
+
+Thanks for the contributions from [@enklht](https://github.com/enklht).
+
+
+## v0.5.3
+
+### Features
+
+- feat: add `stretch` parameter for `#alternatives[]` function class. This allows us to handle cases where the internal element is a context expression.
+- feat: add `config-common(align-enum-marker-with-baseline: true)` for aligning the enum marker with the baseline.
+- feat: add `linebreaks` option to `components.mini-slides`. https://github.com/touying-typ/touying/pull/96
+- feat: add `<touying:skip>` label to skip a new-section-slide.
+- feat: add `config-common(show-hide-set-list-marker-none: true)` to make the markers of `list` and `enum` invisible after `#pause`.
+- feat: add `config-common(bibliography-as-footnote: bibliography(title: none, "ref.bib"))` to display the bibliography in footnotes.
+- refactor: add `config-common(show-strong-with-alert: true)` configuration to display strong text with an alert. (small breaking change for some themes)
+- refactor: refactor `display-current-heading` for preserving heading style in title and subtitle. https://github.com/touying-typ/touying/issues/71
+- refactor: make `new-section-slide-fn` function class can receive `body` parameter. We can use `receive-body-for-new-section-slide-fn` to control it. **(Breaking change)**
+ - For example, you can add `#speaker-note[]` for a new section slide, like `= Section Title \ #speaker-note[]`.
+ - If you don't want to append content to the body of the new section slide, you can use `---` after the section title.
+
+### Fixes
+
+- fix outdated documentation.
+- fix bug of `enable-frozen-states-and-counters` in handout mode.
+- fix unusable `square()` function. https://github.com/touying-typ/touying/issues/73
+- fix hidden footer for `show-notes-on-second-screen: bottom`. https://github.com/touying-typ/touying/issues/89
+- fix metadata element in table cells. https://github.com/touying-typ/touying/issues/77 https://github.com/touying-typ/touying/issues/95
+- fix `auto-offset-for-heading` to `false` by default.
+- fix uncover/only hides more content than it should. https://github.com/touying-typ/touying/issues/85
+- theme(simple): fix wrong title and subtitle. https://github.com/touying-typ/touying/issues/70
+
+
+## v0.5.1 & v0.5.2
+
+- Fix some bugs.
+
+## v0.5.0
+
+This is a significant disruptive version update. Touying has removed many mistakes that resulted from incorrect decisions. We have redesigned numerous features. The goal of this version is to make Touying more user-friendly, more flexible, and more powerful.
+
+**Major changes include:**
+
+- Avoiding closures and OOP syntax, which makes Touying's configuration simpler and allows for the use of document comments to provide more auto-completion information for the slide function.
+ - The existing `#let slide(self: none, ..args) = { .. }` is now `#let slide(..args) = touying-slide-wrapper(self => { .. })`, where `self` is automatically injected.
+ - We can use `config-xxx` syntax to configure Touying, for example, `#show: university-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))`.
+- The `touying-slide` function no longer includes parameters like `section`, `subsection`, and `title`. These will be automatically inserted into the slide as invisible level 1, 2, or 3 headings via `self.headings` (controlled by the `slide-level` configuration).
+ - We can leverage the powerful headings provided by Typst to support numbering, outlines, and bookmarks.
+ - Headings within the `#slide[= XXX]` function will be adjusted to level `slide-level + 1` using the `offset` parameter.
+ - We can use labels on headings to control many aspects, such as supporting the `<touying:hidden>` and other special labels, implementing short headings, or recalling a slide with `#touying-recall()`.
+- Touying now supports the normal use of `set` and `show` rules at any position, without requiring them to be in specific locations.
+
+A simple usage example is shown below, and more examples can be found in the `examples` directory:
+
+```typst
+#import "@preview/touying:0.6.3": *
+#import themes.university: *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: "1.1")
+
+#title-slide()
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+```
+
+**Theme Migration Guide:**
+
+For detailed changes to specific themes, you can refer to the `themes` directory. Generally, if you want to migrate an existing theme, you should:
+
+1. Rename the `register` function to `xxx-theme` and remove the `self` parameter.
+2. Add a `show: touying-slides.with(..)` configuration.
+ - Change `self.methods.colors` to `config-colors(primary: rgb("#xxxxxx"))`.
+ - Change `self.page-args` to `config-page()`.
+ - Change `self.methods.slide = slide` to `config-methods(slide: slide)`.
+ - Change `self.methods.new-section-slide = new-section-slide` to `config-methods(new-section-slide: new-section-slide)`.
+ - Change private theme variables like `self.xxx-footer` to `config-store(footer: [..])`, which you can access through `self.store.footer`.
+ - Move the configuration of headers and footers into the `slide` function rather than in the `xxx-theme` function.
+ - You can directly use `set` or `show` rules in `xxx-theme` or configure them through `config-methods(init: (self: none, body) => { .. })` to fully utilize the `self` parameter.
+3. For `states.current-section-with-numbering`, you can use `utils.display-current-heading(level: 1)` instead.
+ - If you only need the previous heading regardless of whether it is a section or a subsection, use `utils.display-current-heading()`.
+4. The `alert` function can be replaced with `config-methods(alert: utils.alert-with-primary-color)`.
+5. The `touying-outline()` function is no longer needed; you can use `components.adaptive-columns(outline())` instead. Consider using `components.progressive-outline()` or `components.custom-progressive-outline()`.
+6. Replace `states.slide-counter.display() + " / " + states.last-slide-number` with `context utils.slide-counter.display() + " / " + utils.last-slide-number`. That is, we no longer use `states` but `utils`.
+7. Remove the `slides` function; we no longer need this function. Instead of implicitly injecting `title-slide()`, explicitly use `#title-slide()`. If necessary, consider adding it in the `xxx-theme` function.
+8. Change `#let slide(self: none, ..args) = { .. }` to `#let slide(..args) = touying-slide-wrapper(self => { .. })`, where `self` is automatically injected.
+ - Change specific parameter configurations to `self = utils.merge-dicts(self, config-page(fill: self.colors.neutral-lightest))`.
+ - Remove `self = utils.empty-page(self)` and use `config-common(freeze-slide-counter: true)` and `config-page(margin: 0em)` instead.
+ - Change `(self.methods.touying-slide)()` to `touying-slide()`.
+9. You can insert visible headings into slides by configuring `config-common(subslide-preamble: self => text(1.2em, weight: "bold", utils.display-current-heading(depth: self.slide-level)))`.
+10. Finally, don't forget to add document comments to your functions so your users can get better auto-completion hints, especially when using the Tinymist plugin.
+
+**Other Changes:**
+
+- theme(stargazer): new stargazer theme modified from [Coekjan/touying-buaa](https://github.com/Coekjan/touying-buaa).
+- feat: implemented fake frozen states support, allowing you to use numbering and `#pause` normally. This behavior can be controlled with `enable-frozen-states-and-counters`, `frozen-states`, and `frozen-counters` in `config-common()`.
+- feat: implemented `label-only-on-last-subslide` functionality to prevent non-unique label warnings when working with `@equation` and `@figure` in conjunction with `#pause` animations.
+- feat: added the `touying-recall(<label>)` function to replay a specific slide.
+- feat: implemented `nontight-list-enum-and-terms`, which defaults to `true` and forces `list`, `enum`, and `terms` to have their `tight` parameter set to `false`. You can control spacing size with `#set list(spacing: 1em)`.
+- feat: replaced `list` with `terms` implementation to achieve `align-list-marker-with-baseline`, which is off by default.
+- feat: implemented `scale-list-items`, scaling list items by a factor, e.g., `scale-list-items: 0.8` scales list items by 0.8.
+- feat: supported direct use of `#pause` and `#meanwhile` in math expressions, such as `$x + pause y$`.
+- feat: provided `#pause` and `#meanwhile` support for most layout functions, such as `grid` and `table`.
+- feat: added `#show: appendix` support, essentially equivalent to `#show: touying-set-config.with((appendix: true))`.
+- feat: Introduced special labels `<touying:hidden>`, `<touying:unnumbered>`, `<touying:unoutlined>`, `<touying:unbookmarked>` to simplify control over heading behavior.
+- feat: added basic `utils.short-heading` support to display short headings using labels, such as displaying `<sec:my-section>` as "My Section".
+- feat: added `#components.adaptive-columns()` to achieve adaptive columns that span a page, typically used with the `outline()` function.
+- feat: added `#show: magic.bibliography-as-footnote.with(bibliography("ref.bib"))` to display the bibliography in footnotes.
+- feat: added components like `custom-progressive-outline`, `mini-slides`.
+- feat: removed `touying-outline()`, which can be directly replaced with `outline()`.
+- fix: replaced potentially incompatible code, such as `type(s) == "string"` and `locate(loc => { .. })`.
+- fix: Fixed some bugs.
+
+
+
+## v0.4.2
+
+- theme(metropolis): decoupled text color with `neutral-dark` (Breaking change)
+- feat: add mark-style uncover, only and alternatives
+- feat: add warning for styled block for slides
+- feat: add warning for touying-temporary-mark
+- feat: add markup-text for speaker-note
+- fix: fix bug of slides
+
+
+## v0.4.1
+
+### Features
+
+- feat: support builtin outline and bookmark
+- feat: support speaker note for dual-screen
+- feat: add touying-mitex function
+- feat: touying offers [a gallery page](https://github.com/touying-typ/touying/wiki) via wiki
+
+### Fixes
+
+- fix: add outline-slide for dewdrop theme
+- fix: fix regression of default value "auto" for repeat
+
+### Miscellaneous Improvements
+
+- feat: add list support for `touying-outline` function
+- feat: add auto-reset-footnote
+- feat: add `freeze-in-empty-page` for better page counter
+- feat: add `..args` for register method to capture unused arguments
+
+
+## v0.4.0
+
+### Features
+
+- **feat:** support `#footnote[]` for all themes.
+- **feat:** access subslide and repeat in footer and header by `self => self.subslide`.
+- **feat:** support numbered theorem environments by [ctheorems](https://typst.app/universe/package/ctheorems).
+- **feat:** support numbering for sections and subsections.
+
+### Fixes
+
+- **fix:** make nested includes work correctly.
+- **fix:** disable multi-page slides from creating the same section multiple times.
+
+## Breaking changes
+
+- **refactor:** remove `self.padding` and add `self.full-header` `self.full-footer` config.
+
+
+## v0.3.3
+
+- **template:** move template to `touying-aqua` package, make Touying searchable in [Typst Universe Packages](https://typst.app/universe/search?kind=packages)
+- **themes:** fix bugs in university and dewdrop theme
+- **feat:** make set-show rule work without `setting` parameter
+- **feat:** make `composer` parameter more simpler
+- **feat:** add `empty-slide` function
+
+## v0.3.2
+
+- **fix critical bug:** fix `is-sequence` function, make `grid` and `table` work correctly in touying
+- **theme:** add aqua theme, thanks for pride7
+- **theme:** make university theme more configurable
+- **refactor:** don't export variable `s` by default anymore, it will be extracted by `register` function (**Breaking Change**)
+- **meta:** add `categories` and `template` config to `typst.toml` for Typst 0.11
+
+
+## v0.3.1
+
+- fix some typos
+- fix slide-level bug
+- fix bug of pdfpc label
+
+
+## v0.3.0
+
+### Features
+
+- better show-slides mode.
+- support align and pad.
+
+### Documentation
+
+- Add more detailed documentation.
+
+### Refactor
+
+- simplify theme.
+
+### Fix
+
+- fix many bugs.
+
+## v0.2.1
+
+### Features
+
+- **Touying-reducer**: support cetz and fletcher animation
+- **university theme**: add university theme
+
+### Fix
+
+- fix footer progress in metropolis theme
+- fix some bugs in simple and dewdrop themes
+- fix bug that outline does not display more than 4 sections
+
+
+## v0.2.0
+
+- **Object-oriented programming:** Singleton `s`, binding methods `utils.methods(s)` and `(self: obj, ..) => {..}` methods.
+- **Page arguments management:** Instead of using `#set page(..)`, you should use `self.page-args` to retrieve or set page parameters, thereby avoiding unnecessary creation of new pages.
+- **`#pause` for sequence content:** You can use #pause at the outermost level of a slide, including inline and list.
+- **`#pause` for layout functions:** You can use the `composer` parameter to add yourself layout function like `utils.side-by-side`, and simply use multiple pos parameters like `#slide[..][..]`.
+- **`#meanwhile` for synchronous display:** Provide a `#meanwhile` for resetting subslides counter.
+- **`#pause` and `#meanwhile` for math equation:** Provide a `#touying-equation("x + y pause + z")` for math equation animations.
+- **Slides:** Create simple slides using standard headings.
+- **Callback-style `uncover`, `only` and `alternatives`:** Based on the concise syntax provided by Polylux, allow precise control of the timing for displaying content.
+ - You should manually control the number of subslides using the `repeat` parameter.
+- **Transparent cover:** Enable transparent cover using oop syntax like `#let s = (s.methods.enable-transparent-cover)(self: s)`.
+- **Handout mode:** enable handout mode by `#let s = (s.methods.enable-handout-mode)(self: s)`.
+- **Fit-to-width and fit-to-height:** Fit-to-width for title in header and fit-to-height for image.
+ - `utils.fit-to-width(grow: true, shrink: true, width, body)`
+ - `utils.fit-to-height(width: none, prescale-width: none, grow: true, shrink: true, height, body)`
+- **Slides counter:** `states.slide-counter.display() + " / " + states.last-slide-number` and `states.touying-progress(ratio => ..)`.
+- **Appendix:** Freeze the `last-slide-number` to prevent the slide number from increasing further.
+- **Sections:** Touying's built-in section support can be used to display the current section title and show progress.
+ - `section` and `subsection` parameter in `#slide` to register a new section or subsection.
+ - `states.current-section-title` to get the current section.
+ - `states.touying-outline` or `s.methods.touying-outline` to display a outline of sections.
+ - `states.touying-final-sections(sections => ..)` for custom outline display.
+ - `states.touying-progress-with-sections((current-sections: .., final-sections: .., current-slide-number: .., last-slide-number: ..) => ..)` for powerful progress display.
+- **Navigation bar**: Navigation bar like [here](https://github.com/zbowang/BeamerTheme) by `states.touying-progress-with-sections(..)`, in `dewdrop` theme.
+- **Pdfpc:** pdfpc support and export `.pdfpc` file without external tool by `typst query` command simply.
--- /dev/null
+{
+ "label": "外部工具",
+ "position": 6,
+ "link": {
+ "type": "generated-index",
+ "description": "将 Touying 与外部演示工具和编辑器集成。"
+ }
+}
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# Gistd
+
+[Gistd](https://github.com/Myriad-Dreamin/gistd) 即时分享 [typst](https://typst.app) 文档到 Git 和其他网络存储。最重要的特性是它基于 typst.ts 来编译 typst 文档,你可以选择并复制文本!
+
+- [全球节点 (Cloudflare CDN)](https://gistd.myriad-dreamin.com)
+- [亚洲区域 (镜像)](https://gistd-cn.myriad-dreamin.com)
+
+## 加载 GitHub 上的文档
+
+假设你有一个 GitHub 链接,例如:
+
+```
+https://github.com/typst/templates/blob/main/charged-ieee/template/main.typ
+```
+
+只需将 `github.com` 替换为 `gistd.myriad-dreamin.com`:
+
+```
+https://gistd.myriad-dreamin.com/typst/templates/blob/main/charged-ieee/template/main.typ
+```
+
+示例文档:
+
+- [https://gistd.myriad-dreamin.com/johanvx/typst-undergradmath/blob/main/undergradmath.typ](https://gistd.myriad-dreamin.com/johanvx/typst-undergradmath/blob/main/undergradmath.typ)
+- [https://gistd.myriad-dreamin.com/Jollywatt/typst-fletcher/blob/main/docs/manual.typ](https://gistd.myriad-dreamin.com/Jollywatt/typst-fletcher/blob/main/docs/manual.typ)
+- [https://gistd.myriad-dreamin.com/typst/templates/blob/main/charged-ieee/template/main.typ](https://gistd.myriad-dreamin.com/typst/templates/blob/main/charged-ieee/template/main.typ)
+
+## 查看参数
+
+这些 URL 参数可以改变 gistd 的行为。
+
+- `g-page`: 要显示的页码。默认为 `1`。仅在幻灯片模式下可用。
+- `g-mode`: 显示模式。
+ - `doc`: 以文档模式查看文档。
+ - `slide`: 以幻灯片模式查看文档。
+- `g-version`: 要使用的 typst 编译器版本。
+ - 可以是 `v0.13.0`、`v0.13.1`、`v0.14.0` 或 `latest`。
+
+## 幻灯片视图
+
+使用上面提到的 `g-mode=slide` 以幻灯片模式查看文档:
+
+示例文档:
+
+- [一个简单的 touying 幻灯片](https://gistd.myriad-dreamin.com/touying-typ/touying/blob/main/examples/simple.typ?g-mode=slide)
+
+## 通过任意链接加载文档
+
+假设你有一个任意链接,例如:
+
+```
+https://github.com/typst/templates/blob/main/charged-ieee/template/main.typ
+```
+
+在链接前添加 `any-gistd.myriad-dreamin.com`:
+
+```
+https://any-gistd.myriad-dreamin.com/github.com/typst/templates/blob/main/charged-ieee/template/main.typ
+```
+
+`any-gistd.myriad-dreamin.com` 是 `gistd.myriad-dreamin.com/@any` 的别名。
+
+如果某个域名(主机)被特别识别,gistd 将使用相应的方式来提供文档。
+
+- `github.com`: git 协议。
+- `codeberg.org`: git 协议。
+- `localhost` 和其他: 如果主机是 `localhost` 则使用 HTTP 协议,否则使用 https 协议。注意:如果 gistd 无法识别该域名,则不会加载该域名上的其他文件,即 typst 文档无法加载相对于该域名的其他资源。
+
+示例文档:
+
+- [https://any-gistd.myriad-dreamin.com/github.com/Myriad-Dreamin/gistd/raw/main/README.typ](https://any-gistd.myriad-dreamin.com/github.com/Myriad-Dreamin/gistd/raw/main/README.typ)
+- [https://gistd.myriad-dreamin.com/@any/github.com/Myriad-Dreamin/gistd/raw/main/README.typ](https://gistd.myriad-dreamin.com/@any/github.com/Myriad-Dreamin/gistd/raw/main/README.typ)
+
+## 不使用 CORS 代理加载文档
+
+默认情况下,gistd 使用一个受信任的 CORS 代理(`https://underleaf.mgt.workers.dev`)来加载文档。这是因为 GitHub 和 Forgejo 不允许 gistd 直接加载文档。更多详情请参见 [isomorphic-git: 快速入门](https://isomorphic-git.org/docs/en/quickstart)。
+
+然而,你可能希望不使用 CORS 代理来加载文档。你可以通过在查询字符串中添加 `g-cors=false` 来实现。
+
+例如,要加载 `http://localhost:11449/main.typ` 处的文档:
+
+- [https://gistd.myriad-dreamin.com/@http/localhost:11449/main.typ?g-cors=false](https://gistd.myriad-dreamin.com/@http/localhost:11449/main.typ?g-cors=false)
+
+## 使用 HTTP 协议加载文档
+
+`@any` 从 URL 推断协议,而你可以使用 `@http` 来强制使用 HTTP 协议。例如,要加载 `http://localhost:11449/main.typ` 处的文档:
+
+- [https://gistd.myriad-dreamin.com/@http/localhost:11449/main.typ?g-cors=false](https://gistd.myriad-dreamin.com/@http/localhost:11449/main.typ?g-cors=false)
+
+### 开发
+
+安装依赖:
+
+```
+pnpm install
+```
+
+本地开发:
+
+```
+pnpm dev
+```
+
+构建:
+
+```
+pnpm build
+```
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# pdfpc
+
+[pdfpc](https://pdfpc.github.io/) 是一个 "对 PDF 文档具有多显示器支持的演示者控制台"。这意味着,您可以使用它以 PDF 页面的形式显示幻灯片,并且还具有一些已知的出色功能,就像 PowerPoint 一样。
+
+pdfpc 有一个 JSON 格式的 `.pdfpc` 文件,它可以为 PDF slides 提供更多的信息。虽然您可以手动编写它,但你也可以通过 Touying 来管理。
+
+
+## 加入 Metadata
+
+Touying 与 [Polylux](https://polylux.dev/book/external/pdfpc.html) 保持一致,以避免 API 之间的冲突。
+
+例如,你可以通过 `#pdfpc.speaker-note("This is a note that only the speaker will see.")` 加入 notes。
+
+
+## pdfpc 配置
+
+为了加入 pdfpc 配置,你可以使用
+
+```typst
+#pdfpc.config(
+ duration-minutes: 30,
+ start-time: datetime(hour: 14, minute: 10, second: 0),
+ end-time: datetime(hour: 14, minute: 40, second: 0),
+ last-minutes: 5,
+ note-font-size: 12,
+ disable-markdown: false,
+ default-transition: (
+ type: "push",
+ duration-seconds: 2,
+ angle: ltr,
+ alignment: "vertical",
+ direction: "inward",
+ ),
+)
+```
+
+加入对应的配置,具体配置方法可以参考 [Polylux](https://polylux.dev/book/external/pdfpc.html)。
+
+
+## 输出 .pdfpc 文件
+
+假设你的文档为 `./example.typ`,则你可以通过
+
+```sh
+typst query --root . ./example.typ --field value --one "<pdfpc-file>" > ./example.pdfpc
+```
+
+直接导出 `.pdfpc` 文件。
+
+借助 Touying 与 Polylux 的兼容性,你可以让 Polylux 也支持直接导出,只需要加入下面的代码即可。
+
+```typst
+#import "@preview/touying:0.7.3"
+
+#context touying.pdfpc.pdfpc-file(here())
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# Pympress
+
+[Pympress](https://github.com/Cimbali/pympress) 是一种 PDF 演示工具,专为演示文稿和公开演讲等双屏设置而设计。高度可配置、功能齐全且可移植。
+
+
+## 笔记支持
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-notes-on-second-screen: right),
+)
+
+= Animation
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+```
+
+
+
+然后我们就可以使用 pympress 放映了。
+
+
+
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Touying Exporter
+
+[touying-exporter](https://github.com/touying-typ/touying-exporter) 是一个命令行工具,用于将 Touying 演示文稿导出为各种格式。它是专为 Touying 演示文稿设计的,但也可以用于其他 Typst 文件。用于 Touying 的导出演示文稿幻灯片工具。
+
+## Touying 模板
+
+[Touying 模板](https://github.com/touying-typ/touying-template) 用于在 GitHub Pages 上进行在线演示。
+
+演示:https://touying-typ.github.io/touying-template/
+
+使用此模板,请按照以下步骤操作:
+
+1. 点击 `Use this template` 按钮复制仓库。
+2. 点击 `Settings -> Pages -> Branch -> None -> gh-pages -> Save` 启用 GitHub Pages。
+3. 打开链接 `your-name.github.io/repo-name` 开始你的演示。
+
+缺点:无法选中复制文本,如果有对应需要,请使用 [Gistd](https://github.com/Myriad-Dreamin/gistd)。
+
+## HTML 导出
+
+我们生成 SVG 图像文件,并将其与 impress.js 打包成一个 HTML 文件。这样,你可以使用浏览器打开并进行演示,支持 GIF 动画和演讲者备注。
+
+
+
+
+
+[Touying 模板](https://github.com/touying-typ/touying-template) 用于在线演示。[在线查看](https://touying-typ.github.io/touying-template/)
+
+## PPTX 导出
+
+我们生成 PNG 图像文件,并将其打包成 PPTX 文件。这样,你可以使用 PowerPoint 打开并进行演示,支持演讲者备注。
+
+
+
+## 安装
+
+```sh
+pip install touying
+```
+
+## 命令行工具
+
+```text
+usage: touying compile [-h] [--output OUTPUT] [--root ROOT] [--font-paths [FONT_PATHS ...]] [--start-page START_PAGE] [--count COUNT] [--ppi PPI] [--silent SILENT] [--format {html,pptx,pdf,pdfpc}] [--sys-inputs SYS_INPUTS] input
+
+positional arguments:
+ input Input file
+
+options:
+ -h, --help show this help message and exit
+ --output OUTPUT Output file
+ --root ROOT Root directory for typst file
+ --font-paths [FONT_PATHS ...]
+ Paths to custom fonts
+ --start-page START_PAGE
+ Page to start from
+ --count COUNT Number of pages to convert
+ --ppi PPI Pixels per inch for PPTX format
+ --silent SILENT Run silently
+ --format {html,pptx,pdf,pdfpc}
+ Output format
+ --sys-inputs SYS_INPUTS
+ JSON string to pass to typst's sys.inputs
+```
+
+例如:
+
+```sh
+touying compile example.typ
+```
+
+你将得到一个 `example.html` 文件。用你的浏览器打开它,开始你的演示吧 :-)
+
+### 传递变量给 Typst
+
+你可以使用 `--sys-inputs` 参数将变量传递给你的 Typst 文件:
+
+```sh
+touying compile example.typ --sys-inputs '{"title":"My Presentation","author":"John Doe"}'
+```
+
+在你的 Typst 文件中,你可以这样访问这些变量:
+
+```typst
+#let title = sys.inputs.at("title", default: "Default Title")
+#let author = sys.inputs.at("author", default: "Default Author")
+
+= #title
+By #author
+```
+
+## 作为 Python 包使用
+
+```python
+import touying
+
+touying.to_html("example.typ")
+```
--- /dev/null
+---
+sidebar_position: 5
+---
+
+# Typst Preview in Tinymist
+
+VS Code 的 Tinymist 插件提供了优秀的 slide mode,我们可以用其预览和放映 slides。
+
+按下 `Ctrl/Cmd + Shift + P`,并输入 `Typst Preview: Preview current file in slide mode`,就可以打开 slide mode 的预览。
+
+按下 `Ctrl/Cmd + Shift + P`,并输入 `Typst Preview: Preview current file in browser and slide mode`,就可以在浏览器打开 slide mode。
+
+这时候你可以按下 `F11` 之类的键,进入浏览器的全屏模式,就可以用于 slides 放映了。
+
+由于 Typst Preview 是基于 SVG 的,因此可以播放 GIF 动图,这对于动态 slides 很有帮助。
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# 常见问题
+
+本页收集了 Touying 使用中的常见问题与解决方案。
+
+## 主题与配置
+
+### 如何选择和切换主题?
+
+Touying 提供多个内置主题,通过导入并应用主题函数即可切换:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+= Section
+
+== Slide
+
+Using the simple theme.
+```
+
+其他内置主题包括 `themes.default`、`themes.metropolis`、`themes.aqua`、`themes.dewdrop`、`themes.stargazer`、`themes.university` 等。
+
+### 如何自定义主题颜色?
+
+使用 `config-colors(primary: ...)` 自定义主题的主色调:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-colors(primary: rgb("#d94f00")),
+ config-info(title: [Custom Color], author: [Author]),
+)
+= Section
+
+== Slide
+
+The header now uses the custom primary color.
+```
+
+---
+
+## config-common 配置参考
+
+### config-common 有哪些常用配置项?
+
+`config-common` 是 Touying 的核心配置函数,以下是常用配置项及其默认值和说明:
+
+| 配置项 | 默认值 | 说明 |
+|--------|--------|------|
+| `handout` | `false` | 讲义模式,禁用动画 |
+| `slide-level` | `2` | 控制哪个标题级别创建新幻灯片 |
+| `frozen-counters` | `()` | 冻结计数器列表 |
+| `show-strong-with-alert` | `true` | 粗体文本使用 alert 样式 |
+| `show-notes-on-second-screen` | `none` | 第二屏幕演讲者备注(`none`/`right`/`left`) |
+| `horizontal-line-to-pagebreak` | `true` | 将 `---` 水平线转换为分页符 |
+| `nontight-list-enum-and-terms` | `false` | 列表项间距控制 |
+| `show-hide-set-list-marker-none` | `true` | `#pause` 后隐藏列表标记 |
+| `show-bibliography-as-footnote` | `none` | 参考文献显示为脚注 |
+| `scale-list-items` | `none` | 缩放列表项大小 |
+| `new-section-slide-fn` | `none` | 章节幻灯片函数 |
+| `freeze-slide-counter` | `false` | 冻结幻灯片计数器 |
+| `enable-pdfpc` | `true` | 启用 pdfpc 支持 |
+| `breakable` | `true` | 是否允许幻灯片内容溢出到下一页 |
+| `clip` | `false` | 是否裁剪溢出内容(仅在 `breakable: false` 时生效) |
+| `detect-overflow` | `true` | 是否检测溢出并报错(仅在 `breakable: false` 时生效) |
+
+### 如何防止幻灯片内容溢出到下一页?
+
+使用 `config-common(breakable: false)` 可以防止幻灯片内容自动溢出到下一页。默认情况下(`breakable: true`),超出幻灯片高度的内容会自动创建新页面;设置为 `false` 后,内容将被限制在单页内,这对于需要保证源码与输出页面一一对应的场景(如 AI 智能体工作流)非常有用。
+
+配合使用的参数:
+
+- **`clip`**(默认 `false`):设为 `true` 时,超出幻灯片高度的内容会被视觉截断。
+- **`detect-overflow`**(默认 `true`):设为 `true` 时,会通过布局测量检测溢出,一旦内容高度超出幻灯片高度则直接 `panic()` 报错,便于及早发现问题;设为 `false` 可避免额外的布局开销。
+
+```typst
+// Prevent overflow, panic on overflow (default behavior when breakable: false)
+#show: simple-theme.with(
+ config-common(breakable: false),
+)
+
+// Prevent overflow and visually clip overflowing content
+#show: simple-theme.with(
+ config-common(breakable: false, clip: true),
+)
+
+// Prevent overflow, disable overflow detection (performance-first)
+#show: simple-theme.with(
+ config-common(breakable: false, detect-overflow: false),
+)
+```
+
+也可以在演示文稿中途通过 `touying-set-config` 切换:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme.with(config-common(breakable: false))
+== This slide's overflow will be clipped
+
+// Enable clipping for a specific slide
+#show: touying-set-config.with(config-common(clip: true))
+
+#lorem(500)
+```
+
+### 如何使用半透明遮罩替代完全隐藏?
+
+使用 `config-methods(cover: utils.semi-transparent-cover)` 配置,使被隐藏的内容以半透明形式显示:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-methods(cover: utils.semi-transparent-cover),
+)
+
+= Section
+
+== Slide
+
+#pause
+
+This content is shown with a semi-transparent cover.
+```
+
+### 如何使用 preamble 在每张幻灯片前插入内容?
+
+使用 `config-common(preamble: ...)` 在每张幻灯片前插入固定内容,`subslide-preamble` 在子幻灯片前插入:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(
+ preamble: text(gray)[This appears before every slide],
+ subslide-preamble: (2: [Special prelude for subslide 2]),
+ ),
+)
+
+= Section
+
+== Slide
+
+Content here.
+
+#pause
+
+More content.
+```
+
+### 如何使用 `---` 分隔幻灯片?
+
+当 `horizontal-line-to-pagebreak: true` 时,可以在标题之间使用 `---` 来创建新幻灯片:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+= Section
+
+== First Slide
+
+Content here.
+
+---
+
+== Second Slide
+
+Created by `---`.
+```
+
+### 如何让列表项在 #pause 后隐藏标记符号?
+
+`show-hide-set-list-marker-none: true` 会在 `#pause` 后隐藏列表标记:
+
+```typst
+#show: simple-theme.with(
+ config-common(show-hide-set-list-marker-none: true),
+)
+```
+
+### 如何缩放列表项大小?
+
+使用 `scale-list-items: 0.8` 将列表项缩小到原始大小的 80%:
+
+```typst
+#show: simple-theme.with(
+ config-common(scale-list-items: 0.8),
+)
+```
+
+---
+
+## 布局与分栏
+
+### 如何创建两栏布局?
+
+使用带 `composer` 参数的 `slide` 将内容分成多列:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide(composer: (1fr, 1fr))[
+ == Left Column
+
+ Some text on the left side.
+][
+ == Right Column
+
+ Some text on the right side.
+]
+```
+
+如需不等宽的列,可调整分数比例,例如 `(2fr, 1fr)`。
+
+### 如何将内容放置在绝对位置?
+
+使用 Typst 的 `place` 函数进行绝对定位:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ Main slide content here.
+
+ #place(bottom + right, dx: -1em, dy: -1em)[
+ #rect(fill: blue.lighten(80%), inset: 0.5em)[Note]
+ ]
+]
+```
+
+### 如何让内容填满幻灯片的剩余高度或宽度?
+
+使用 `utils.fit-to-height` 或 `utils.fit-to-width`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ #utils.fit-to-width(1fr)[
+ == This heading fills the slide width
+ ]
+
+ Some content below.
+]
+```
+
+---
+
+## 目录
+
+### 如何显示目录?
+
+用 `components.adaptive-columns` 包裹 Typst 内置的 `outline`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme.with(aspect-ratio: "16-9")
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= First Section
+
+== Introduction
+
+Hello, Touying!
+
+= Second Section
+
+== Details
+
+More content here.
+```
+
+`<touying:hidden>` 标签可将目录幻灯片本身从目录中隐藏。
+
+### 如何为目录中的章节添加编号?
+
+结合 `numbly` 包和 `#set heading(numbering: ...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#import "@preview/numbly:0.1.0": numbly
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+#show: simple-theme.with(aspect-ratio: "16-9")
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= First Section
+
+== First Slide
+
+= Second Section
+
+== Second Slide
+```
+
+### 如何显示带进度高亮的目录?
+
+使用 `components.progressive-outline` 高亮当前章节:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+#show: dewdrop-theme.with(aspect-ratio: "16-9")
+= First Section
+
+== Outline
+
+#components.progressive-outline()
+
+= Second Section
+
+== Slide
+```
+
+---
+
+## 参考文献与引用
+
+### 如何将引用显示为脚注?
+
+将 `bibliography(...)` 值传递给 `config-common(show-bibliography-as-footnote: ...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#let bib = bytes(
+ "@book{knuth,
+ title={The Art of Computer Programming},
+ author={Donald E. Knuth},
+ year={1968},
+ publisher={Addison-Wesley},
+ }",
+)
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-bibliography-as-footnote: bibliography(bib)),
+)
+= Citations
+
+== Footnote Example
+
+This is a famous book. @knuth
+```
+
+### 如何在末尾添加参考文献幻灯片?
+
+使用 `magic.bibliography(...)` 显示参考文献幻灯片:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#let bib = bytes(
+ "@book{knuth,
+ title={The Art of Computer Programming},
+ author={Donald E. Knuth},
+ year={1968},
+ publisher={Addison-Wesley},
+ }",
+)
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-bibliography-as-footnote: bibliography(bib)),
+)
+= Intro
+
+== Slide
+
+Some cited content. @knuth
+
+== References
+
+#magic.bibliography(title: none)
+```
+
+---
+
+## 演讲者备注
+
+### 如何为幻灯片添加演讲者备注?
+
+在幻灯片的任意位置使用 `#speaker-note[...]` 函数:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ == My Slide
+
+ Visible content here.
+
+ #speaker-note[
+ - Remind the audience of the previous topic.
+ - Emphasize the key takeaway.
+ - Time check: should be at 10 min mark.
+ ]
+]
+```
+
+演讲者备注默认不会出现在幻灯片输出中。
+
+### 如何在第二屏幕上显示演讲者备注?
+
+使用 `config-common(show-notes-on-second-screen: right)` 在幻灯片旁边显示备注:
+
+```typst
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-notes-on-second-screen: right),
+)
+```
+
+此功能与 [pdfpc](https://pdfpc.github.io/) 和 [pympress](https://github.com/Cimbali/pympress) 等演示工具兼容。
+
+---
+
+## 幻灯片编号与附录
+
+### 如何在页脚显示幻灯片编号?
+
+使用 `utils.slide-counter.display()` 显示当前编号,`utils.last-slide-number` 显示总数:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ footer: context [
+ #utils.slide-counter.display() / #utils.last-slide-number
+ ],
+ ),
+)
+= Section
+
+== First Slide
+
+The footer shows the slide number.
+
+== Second Slide
+
+Still counting.
+```
+
+### 如何将幻灯片标记为不计数?
+
+在标题上添加 `<touying:unnumbered>` 标签:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+= Title Slide <touying:unnumbered>
+
+== Welcome
+
+This slide is not counted.
+
+== Normal Slide
+
+This slide is counted.
+```
+
+### 如何使用附录以不影响幻灯片总数?
+
+在主要内容之后使用 `#show: appendix`。此后的幻灯片不会递增幻灯片计数器:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme.with(aspect-ratio: "16-9")
+= Main Content
+
+== Introduction
+
+This is slide 1.
+
+== Results
+
+This is slide 2.
+
+#show: appendix
+
+= Appendix
+
+== Extra Material
+
+This slide is in the appendix and does not increment the main counter.
+```
+
+---
+
+## 动画与动态内容
+
+### 如何使用 `#pause` 逐步展示内容?
+
+在 `#slide` 内的内容块之间放置 `#pause`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ First point.
+
+ #pause
+
+ Second point revealed on click.
+
+ #pause
+
+ Third point revealed on second click.
+]
+```
+
+### 如何仅在特定子幻灯片上显示内容?
+
+使用 `#only("...")` 在特定子幻灯片上显示内容,或用 `#uncover("...")` 显示内容同时保留其占位空间:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ #only("1")[Shown on subslide 1 only.]
+ #only("2-")[Shown from subslide 2 onward.]
+ #uncover("3-")[Revealed on subslide 3, space reserved before.]
+]
+```
+
+### 为什么 `#pause` 在 `context` 表达式内不起作用?
+
+`#pause` 使用元数据注入机制,在 `context { ... }` 块内无法正常工作。请改用回调式 `slide` 来访问 `self.subslide`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide(self => {
+ let (uncover, only) = utils.methods(self)
+ [First content.]
+ linebreak()
+ uncover("2-")[Revealed on subslide 2.]
+ linebreak()
+ only("3")[Only on subslide 3.]
+})
+```
+
+### 如何在 CeTZ 绘图中使用 `#pause`?
+
+使用 `touying-reducer` 包裹 CeTZ canvas,使 Touying 能够为其添加动画:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#import "@preview/cetz:0.5.0"
+
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+
+#slide[
+ #cetz-canvas({
+ import cetz.draw: *
+ rect((0, 0), (4, 3))
+ (pause,)
+ circle((2, 1.5), radius: 1)
+ })
+]
+```
+
+### 如何在 Fletcher 图表中使用 `#pause`?
+
+使用 `touying-reducer` 包裹 Fletcher 图表:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge
+
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+
+#slide[
+ #fletcher-diagram(
+ node((0, 0), [A]),
+ edge("->"),
+ (pause,),
+ node((1, 0), [B]),
+ )
+]
+```
+
+### 如何在子幻灯片间展示替换内容?
+
+使用 `#alternatives` 在不同版本的内容之间切换:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ The answer is: #alternatives[42][*forty-two*][_the ultimate answer_].
+]
+```
+
+### 如何开启讲义模式(禁用动画)?
+
+在主题设置中使用 `config-common(handout: true)`:
+
+```typst
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(handout: true),
+)
+```
+
+在讲义模式下,每张幻灯片只输出最后一个子幻灯片。
+
+---
+
+## 字体与文本
+
+### 如何更改演示文稿的字体?
+
+在主题设置之前或之后使用 `#set text(...)` 规则:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-info(title: [Custom Font]),
+)
+#set text(font: "New Computer Modern", size: 22pt)
+= Section
+
+== Slide
+
+Text now uses the custom font.
+```
+
+对于数学公式,还需设置数学字体:
+
+```typst
+#show math.equation: set text(font: "New Computer Modern Math")
+```
+
+### 如何对段落文本进行两端对齐?
+
+使用 `#set par(justify: true)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#set par(justify: true)
+#slide[
+ == Justified Text
+
+ #lorem(40)
+]
+```
+
+---
+
+## 标题与章节
+
+### 如何禁用自动章节幻灯片?
+
+设置 `config-common(new-section-slide-fn: none)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-common(new-section-slide-fn: none),
+ config-info(title: [No Auto Sections]),
+)
+= Section
+
+== Slide
+
+No automatic section slide was created for the `= Section` heading.
+```
+
+### 如何为带有章节幻灯片的章节添加内容?
+
+使用 `pagebreak()` 或 `---` 强制新建一页,然后在该页编写内容。
+```example
+>>>#import "@preview/touying:0.7.3": *
+>>>#import themes.metropolis: *
+>>>
+>>>#show: metropolis-theme.with(
+>>> aspect-ratio: "16-9",
+>>> config-info(title: [content slides next to section slides]),
+>>>)
+
+= Section
+---
+Here is my content for this section.
+
+== Slide
+And this works normally.
+```
+
+你也可以设置 `config-common(receive-body-for-new-section-slide-fn: false)`。但这样会导致无法为章节幻灯片编写演讲者备注。
+
+### 如何完全隐藏一张幻灯片?
+
+在幻灯片标题上添加 `<touying:hidden>` 标签:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+== Visible Slide
+
+This slide appears in the output.
+
+== Hidden Slide <touying:hidden>
+
+This slide is hidden and does not appear in the output or outline.
+
+== Another Visible Slide
+
+Back to normal.
+```
+
+### 如何将幻灯片从目录中排除但仍然显示?
+
+使用 `<touying:unoutlined>` 标签:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= Section
+
+== Normal Slide
+
+Appears in the outline.
+
+== Interstitial Slide <touying:unoutlined>
+
+This slide shows but is not listed in the outline.
+
+== Another Normal Slide
+
+Also appears in the outline.
+```
+
+### 如何控制哪个标题级别创建新幻灯片?
+
+使用 `config-common(slide-level: ...)`,默认值因主题而异:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(slide-level: 2),
+)
+= Section
+
+This text is part of the section slide.
+
+== Subsection Slide
+
+Each `==` heading creates a new slide.
+
+=== Sub-subheading
+
+Sub-subheadings do not create new slides.
+```
+
+### 如何添加自定义页眉或页脚?
+
+使用 `config-page(header: ..., footer: ...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ header: text(gray)[My Custom Header],
+ footer: context align(right, text(gray)[
+ Slide #utils.slide-counter.display()
+ ]),
+ ),
+)
+= Section
+
+== Slide
+
+Slide with a custom header and footer.
+```
+
+---
+
+## 测试与开发
+
+### 如何运行 Touying 的测试套件?
+
+Touying 使用 [tytanic](https://github.com/Myriad-Dreamin/tytanic) 测试框架。
+
+安装 tytanic:
+
+```bash
+cargo binstall tytanic
+```
+
+运行测试:
+
+```bash
+tt run
+```
+
+测试位于 `tests/` 目录下,分为:
+
+- `features/` — 功能测试
+- `themes/` — 主题测试
+- `integration/` — 第三方包集成测试(cetz、fletcher、pinit、theorion、codly、mitex)
+- `issues/` — 回归测试
+- `examples/` — 示例测试
+
+### 如何为 Touying 贡献代码?
+
+贡献流程:
+
+1. Fork [touying-typ/touying](https://github.com/touying-typ/touying) 仓库
+2. 创建功能分支:`git checkout -b feature/my-feature`
+3. 修改代码并用 [typstyle](https://github.com/Myriad-Dreamin/typstyle) 格式化
+4. 运行 `tt run` 确保所有测试通过
+5. 提交并推送到你的 fork
+6. 创建 Pull Request
+
+---
+
+## 其他问题
+
+### 如何设置演示文稿的标题、作者和日期?
+
+使用 `config-info(...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [My Presentation],
+ subtitle: [A Subtitle],
+ author: [Jane Doe],
+ date: datetime.today(),
+ institution: [My University],
+ ),
+)
+#title-slide()
+= Introduction
+
+== First Slide
+
+Content here.
+```
+
+### 如何为单张幻灯片覆盖配置?
+
+使用 `touying-set-config` 包裹需要更改的内容:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme
+#slide[
+ Normal slide.
+]
+
+#touying-set-config(config-page(fill: rgb("#fff3cd")))[
+ #slide[
+ This slide has a yellow background.
+ ]
+]
+
+#slide[
+ Back to normal.
+]
+```
+
+### 如何访问全局或幻灯片的配置信息?
+
+在需要获取配置的位置使用 `touying-get-config`。由于配置只能在 `context` 时机访问,因此基于此进行计算可能会导致问题。
+> 如果在含有局部配置的幻灯片内调用此函数,返回的将是局部配置,而非全局配置。
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#show: simple-theme.with(
+ config-info(author: "Beautiful Name")
+)
+#slide(config:config-colors(primary: rgb("ABCDEF")))[
+ #touying-get-config().info.author
+
+ #touying-get-config().colors.primary
+]
+```
+
+### 如何编译 Touying 演示文稿?
+
+Touying 是一个纯 Typst 包,无需额外的构建步骤:
+
+```bash
+typst compile slides.typ
+```
+
+编辑时进行实时预览:
+
+```bash
+typst watch slides.typ
+```
+
+或者使用 [Typst Preview](https://marketplace.visualstudio.com/items?itemName=mgt19937.typst-preview) VS Code 扩展进行即时编辑器内预览。
+
+### 如何创建多文件演示文稿?
+
+从主入口文件导入 `lib.typ`,并用 `include` 引入各章节:
+
+```typst
+// main.typ
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+#include "intro.typ"
+#include "methods.typ"
+#include "results.typ"
+```
+
+每个被引入的文件正常使用标题即可,无需在每个文件中重复导入。
+
+### 如何在页眉或页脚中显示当前章节名称?
+
+使用 `utils.display-current-heading(...)` 或 `utils.display-current-short-heading(...)`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ header: context text(gray)[
+ #utils.display-current-heading(level: 1)
+ ],
+ ),
+)
+= My Section
+
+== Slide
+
+The header shows the current section name.
+```
+
+### 如何将 Touying 与 `pinit` 包配合使用?
+
+正常导入两个包并在幻灯片中使用 `#pin`/`#pinit-highlight`:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/pinit:0.2.2": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+#slide[
+ A #pin(1)key term#pin(2) to highlight.
+
+ #pinit-highlight(1, 2)
+]
+```
+
+如需带动画的 pin 展示效果,请使用回调式 slide,以便 `#pause` 与 pinit 正确交互。
+
+### 如何在子幻灯片间冻结计数器(图表、公式)?
+
+使用 `config-common(frozen-counters: true)` 防止计数器在子幻灯片之间递增:
+
+```typst
+#show: simple-theme.with(
+ config-common(frozen-counters: true),
+)
+```
+
+### 如何禁用/启用警告?
+
+Touying 使用 `uniwarn` 来处理其命名空间为 `touying` 的警告。
+我们在 Touying 中绑定了相关函数,因此你可以直接这样做:
+
+```typst
+#import "@preview/touying:0.7.1": *
+
+// 禁用 Touying 发出的警告
+#touying-disable-warnings
+// 重新启用 Touying 发出的警告
+#touying-enable-warnings
+````
+
+你也可以这样做:
+
+```typst
+#import "@preview/uniwarn:0.1.0"
+#uniwarn.disable-warnings("touying")
+#uniwarn.enable-warnings("touying")
+```
+
--- /dev/null
+{
+ "label": "包集成",
+ "position": 4,
+ "link": {
+ "type": "generated-index",
+ "description": "了解如何将第三方 Typst 包与 Touying 集成,以获得更强大的功能。"
+ }
+}
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# CeTZ
+
+Touying 提供了 `touying-reducer`,它能为 cetz 与 fletcher 加入 `pause` 和 `meanwhile` 动画。
+
+## 简单动画
+
+一个例子:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: metropolis-theme.with(aspect-ratio: "16-9")
+
+// cetz animation
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0,0), (5,5))
+
+ (pause,)
+
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+
+ (pause,)
+
+ line((0,0), (2.5, 2.5), name: "line")
+ })
+]
+
+// fletcher animation
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1,0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0,0), `reading`, radius: 2em),
+ edge((0,0), (0,0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1,0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2,0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0,0), (2,0), `close()`, "-|>", bend: -40deg),
+ )
+]
+```
+
+
+## only 与 uncover
+
+事实上,我们也可以在 cetz 内部使用 `only` 和 `uncover`,只是需要一点技巧:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/cetz:0.5.0"
+#import themes.simple: *
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ Cetz in Touying in subslide #self.subslide:
+
+ #cetz.canvas({
+ import cetz.draw: *
+ let uncover = uncover.with(cover-fn: hide.with(bounds: true))
+
+ rect((0,0), (5,5))
+
+ uncover("2-3", {
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+ })
+
+ only(3, line((0,0), (2.5, 2.5), name: "line"))
+ })
+])
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 5
+---
+
+# Codly
+
+[Codly](https://github.com/Dherse/codly) 是一个 Typst 包,提供带有语言图标、行号和语法高亮的精美代码块。
+
+## 设置
+
+由于 Touying 在每个子幻灯片上都会重新渲染内容,`codly` 的每页状态必须在每张幻灯片绘制前恢复。将 codly 的初始化作为 `preamble` 传入:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#import "@preview/codly:1.3.0": *
+#import "@preview/codly-languages:0.1.10": *
+
+#show: codly-init.with()
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(preamble: {
+ codly(languages: codly-languages)
+ }),
+)
+
+== First Slide
+
+#raw(lang: "rust", block: true,
+`pub fn main() {
+ println!("Hello, world!");
+}`.text)
+```
+
+## 动画代码块
+
+你可以使用 `#pause` 或 `#only` 逐行展示代码。但请注意,`#pause` 无法直接在 `raw` 块内使用——请使用 `touying-raw` 实现动画代码:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+== Animated Raw Block
+
+#touying-raw(lang: "python", ```
+print("Step 1")
+// pause
+print("Step 2")
+// pause
+print("Step 3")
+```)
+```
+
+:::tip
+
+`touying-raw` 使用特殊注释标记(`// pause`、`// meanwhile`)触发动画步骤,使源码保持可读性。
+
+:::
+
+## Codly + 动画
+
+若要将完整 codly 样式的代码块与动画相结合,可将 `touying-raw` 与 `config-common(preamble: ...)` 搭配使用:
+
+```typst
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(preamble: {
+ codly(languages: codly-languages)
+ }),
+)
+
+== Animated Code
+
+#touying-raw(lang: "python", ```
+def greet(name):
+// pause
+ return f"Hello, {name}!"
+// pause
+print(greet("World"))
+```)
+```
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# Fletcher
+
+Touying 提供了 `touying-reducer`,它能为 fletcher 加入 `pause` 和 `meanwhile` 动画。
+
+一个例子:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: metropolis-theme.with(aspect-ratio: "16-9")
+
+// cetz animation
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0,0), (5,5))
+
+ (pause,)
+
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+
+ (pause,)
+
+ line((0,0), (2.5, 2.5), name: "line")
+ })
+]
+
+// fletcher animation
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1,0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0,0), `reading`, radius: 2em),
+ edge((0,0), (0,0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1,0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2,0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0,0), (2,0), `close()`, "-|>", bend: -40deg),
+ )
+]
+```
+
+一个 callback-style 的例子:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/fletcher:0.5.8" as fletcher: diagram, edge, node
+#show: themes.simple.simple-theme.with(aspect-ratio: "16-9")
+
+#let diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#slide(repeat: 6, self => {
+ let (uncover, only, alternatives) = utils.methods(self)
+ let uncover = uncover.with(cover-fn: fletcher.hide)
+ diagram(
+ node((0, 0), name: <A>)[$A$],
+ pause,
+ edge("->"),
+ node((1, 0), name: <B>)[$B$],
+ pause,
+ edge("->"),
+ node((2, 0), name: <C>)[$C$],
+ uncover("4,6", edge(<A>, "~", <B>, bend: 40deg, stroke: red)),
+ only("5,6", edge(<B>, "~", <C>, bend: 40deg, stroke: green)),
+ only("6", edge(<C>, "~", <A>, bend: 40deg, stroke: blue)),
+ )
+})
+```
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# MiTeX
+
+在创建 slides 的过程中,往往我们已经有了一个 LaTeX 数学公式,只是想贴到 slides 的里面,而不想把它转写成 Typst 数学公式,这时候我们就可以用 [MiTeX](https://github.com/mitex-rs/mitex) 了。
+
+示例:
+
+```example
+#import "@preview/mitex:0.2.6": *
+
+Write inline equations like #mi("x") or #mi[y].
+
+Also block equations (this case is from #text(blue.lighten(20%), link("https://katex.org/")[katex.org])):
+
+#mitex(`
+ \newcommand{\f}[2]{#1f(#2)}
+ \f\relax{x} = \int_{-\infty}^\infty
+ \f\hat\xi\,e^{2 \pi i \xi x}
+ \,d\xi
+`)
+```
+
+Touying 也提供了一个 `touying-mitex` 函数,用法如
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/mitex:0.2.6": *
+#import themes.simple: *
+#show: simple-theme
+
+#touying-mitex(mitex, `
+ f(x) &= \pause x^2 + 2x + 1 \\
+ &= \pause (x + 1)^2 \\
+`)
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Pinit
+
+[Pinit](https://github.com/OrangeX4/typst-pinit/) 包其提供了一个基于页面绝对定位与基于「图钉」pins 相对定位的能力,可以很方便地为 slides 实现箭头指示与解释说明的效果。
+
+## 简单示例
+
+```example
+#import "@preview/pinit:0.2.2": *
+
+#set text(size: 24pt)
+
+A simple #pin(1)highlighted text#pin(2).
+
+#pinit-highlight(1, 2)
+
+#pinit-point-from(2)[It is simple.]
+```
+
+另一个 [示例](https://github.com/OrangeX4/typst-pinit/blob/main/examples/equation-desc.typ):
+
+
+
+
+
+## 复杂示例
+
+
+一个与 Touying 共同使用的示例:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+#import "@preview/pinit:0.2.2": *
+
+#set text(size: 20pt, font: "Calibri", ligatures: false)
+#show heading: set text(weight: "regular")
+#show heading: set block(above: 1.4em, below: 1em)
+#show heading.where(level: 1): set text(size: 1.5em)
+
+// Useful functions
+#let crimson = rgb("#c00000")
+#let greybox(..args, body) = rect(fill: luma(95%), stroke: 0.5pt, inset: 0pt, outset: 10pt, ..args, body)
+#let redbold(body) = {
+ set text(fill: crimson, weight: "bold")
+ body
+}
+#let blueit(body) = {
+ set text(fill: blue)
+ body
+}
+
+#show: default-theme.with(aspect-ratio: "4-3")
+
+// Main body
+#slide[
+ #set heading(offset: 0)
+
+ = Asymptotic Notation: $O$
+
+ Use #pin("h1")asymptotic notations#pin("h2") to describe asymptotic efficiency of algorithms.
+ (Ignore constant coefficients and lower-order terms.)
+
+ #pause
+
+ #greybox[
+ Given a function $g(n)$, we denote by $O(g(n))$ the following *set of functions*:
+ #redbold(${f(n): "exists" c > 0 "and" n_0 > 0, "such that" f(n) <= c dot g(n) "for all" n >= n_0}$)
+ ]
+
+ #pinit-highlight("h1", "h2")
+
+ #pause
+
+ $f(n) = O(g(n))$: #pin(1)$f(n)$ is *asymptotically smaller* than $g(n)$.#pin(2)
+
+ #pause
+
+ $f(n) redbold(in) O(g(n))$: $f(n)$ is *asymptotically* #redbold[at most] $g(n)$.
+
+ #only("4-", pinit-line(stroke: 3pt + crimson, start-dy: -0.25em, end-dy: -0.25em, 1, 2))
+
+ #pause
+
+ #block[Insertion Sort as an #pin("r1")example#pin("r2"):]
+
+ - Best Case: $T(n) approx c n + c' n - c''$ #pin(3)
+ - Worst case: $T(n) approx c n + (c' \/ 2) n^2 - c''$ #pin(4)
+
+ #pinit-rect("r1", "r2")
+
+ #pause
+
+ #pinit-place(3, dx: 15pt, dy: -15pt)[#redbold[$T(n) = O(n)$]]
+ #pinit-place(4, dx: 15pt, dy: -15pt)[#redbold[$T(n) = O(n)$]]
+
+ #pause
+
+ #blueit[Q: Is $n^(3) = O(n^2)$#pin("que")? How to prove your answer#pin("ans")?]
+
+ #pause
+
+ #pinit-point-to("que", fill: crimson, redbold[No.])
+ #pinit-point-from("ans", body-dx: -150pt)[
+ Show that the equation $(3/2)^n >= c$ \
+ has infinitely many solutions for $n$.
+ ]
+]
+```
--- /dev/null
+---
+sidebar_position: 6
+---
+
+# Theorion
+
+Touying 能够与 [Theorion](https://github.com/OrangeX4/typst-theorion) 包一起正常工作,你可以直接使用 [Theorion](https://github.com/OrangeX4/typst-theorion) 包。其中,你还可以使用 `#set heading(numbering: "1.1")` 为 sections 和 subsections 设置 numbering。
+
+**注意:为了让 `#pause` 等动画命令与 theorion 一起正常工作,你需要使用 `config-common(frozen-counters: (theorem-counter,))` 来绑定需要冻结的计数器。**
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+#import "@preview/numbly:0.1.0": numbly
+#import "@preview/theorion:0.6.0": *
+#import cosmos.clouds: *
+#show: show-theorion
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-common(frozen-counters: (theorem-counter,)), // freeze theorem counter for animation
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+= Theorems
+
+== Prime numbers
+
+#definition[
+ A natural number is called a #highlight[_prime number_] if it is greater
+ than 1 and cannot be written as the product of two smaller natural numbers.
+]
+#example[
+ The numbers $2$, $3$, and $17$ are prime.
+ @cor_largest_prime shows that this list is not exhaustive!
+]
+
+#pause
+
+#theorem(title: "Euclid")[
+ There are infinitely many primes.
+]
+
+#pagebreak(weak: true)
+
+#proof[
+ Suppose to the contrary that $p_1, p_2, dots, p_n$ is a finite enumeration
+ of all primes. Set $P = p_1 p_2 dots p_n$. Since $P + 1$ is not in our list,
+ it cannot be prime. Thus, some prime factor $p_j$ divides $P + 1$. Since
+ $p_j$ also divides $P$, it must divide the difference $(P + 1) - P = 1$, a
+ contradiction.
+]
+
+#corollary[
+ There is no largest prime number.
+] <cor_largest_prime>
+#corollary[
+ There are infinitely many composite numbers.
+]
+
+#theorem[
+ There are arbitrarily long stretches of composite numbers.
+]
+
+#proof[
+ For any $n > 2$, consider $
+ n! + 2, quad n! + 3, quad ..., quad n! + n
+ $
+]
+```
+
+## 为什么需要 `frozen-counters`
+
+Touying 通过重新求值幻灯片内容来渲染每个子幻灯片。若不使用 `frozen-counters`,Theorion 内部的 `theorem-counter` 会在每个子幻灯片上递增,导致定理编号意外跳变。
+
+`config-common(frozen-counters: (theorem-counter,))` 会告知 Touying 在每张幻灯片开始时捕获计数器的值,并在渲染每个子幻灯片前将其恢复,从而保证动画步骤间的定理编号保持一致。
+
+## 冻结多个计数器
+
+如果你还有需要冻结的图表计数器,可以这样配置:
+
+```typst
+config-common(frozen-counters: (theorem-counter, figure.where(kind: image)))
+```
+
+## Cosmos 样式
+
+Theorion 内置了多种视觉样式。上述示例使用的是 `cosmos.clouds`,此外还有 `cosmos.fancy`、`cosmos.aurora` 等。详情请参阅 [Theorion 文档](https://github.com/OrangeX4/typst-theorion)。
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Touying 介绍
+
+[Touying](https://github.com/touying-typ/touying) 是为 [Typst](https://typst.app/) 开发的强大幻灯片/演示文稿包。Touying 类似于 LaTeX 的 Beamer,但得益于 Typst 的现代语法和极速编译,体验更为出色。在本文档中,我们使用 **slides** 指代整套幻灯片,**slide** 指代单张幻灯片,**subslide** 指代动画步骤所产生的子页面。
+
+## 为什么使用 Touying?
+
+- **相较于 PowerPoint** — Touying 遵循「内容与样式分离」的理念。你只需编写带有轻量标记的纯文本,主题会自动处理视觉设计。这对于包含代码块、数学公式和定理环境的科研类演示文稿尤为高效。
+- **相较于 Markdown Slides** — Typst 提供了精细的排版控制能力(页眉、页脚、自定义布局以及一流的数学支持),这是基于 Markdown 的工具难以实现的。Touying 还提供了 `#pause` 和 `#meanwhile` 标记,让渐进式动画在代码优先的工作流中自然流畅。
+- **相较于 Beamer** — Touying 的编译速度以毫秒计,而非秒乃至数十秒。其语法更为简洁,创建或修改主题也更加直接。在功能上,Touying 与 Beamer 高度对标,并额外提供了针对 CeTZ/Fletcher 图表的 `touying-reducer` 等便利功能。
+- **相较于 Polylux** — Touying 实现 `#pause` 时不依赖 `counter` 和 `locate`,从而避免了这些原语带来的性能损耗。Touying 还提供了更丰富的主题工具集以及统一的配置 API,让你以最少的改动即可切换主题。
+
+## 名称来源
+
+「Touying」取自中文「投影」(tóuyǐng),意为"投射/放映"——正如 LaTeX 世界里德文单词 *Beamer* 意为投影仪一样。
+
+## 在哪里编写幻灯片
+
+你有两种主要选择:
+
+| 选项 | 说明 |
+|------|------|
+| **[Typst Web App](https://typst.app/)** | 基于浏览器的编辑器,无需安装;打开 `typst.app`,创建新项目即可开始编写。支持实时预览和协作。 |
+| **[Tinymist for VS Code](https://marketplace.visualstudio.com/items?itemName=myriad-dreamin.tinymist)** | 功能完整的 Typst LSP 扩展。提供语法高亮、自动补全、错误诊断以及内置幻灯片预览面板。 |
+
+两种方式均会自动从 Typst 包注册表下载 Touying,无需单独安装。
+
+## Universe
+
+厌倦了[内置主题](https://touying-typ.github.io/themes/)?
+
+[Typst Universe](https://typst.app/universe/search/?q=touying) 上有着 Touying 用户上传的多种多样的主题,多浏览一下,说不定你会喜欢。
+
+## 画廊
+
+Touying 提供了一个[画廊](https://github.com/touying-typ/touying/wiki),社区成员可在此分享自己的幻灯片。欢迎你贡献自己的作品!
+
+## 贡献
+
+Touying 是免费、开源且社区驱动的项目。欢迎访问 [GitHub](https://github.com/touying-typ/touying) 提交 issue、发起 PR,或加入 [touying-typ](https://github.com/touying-typ) 组织。
+
+## License
+
+Touying is released under the [MIT license](https://github.com/touying-typ/touying/blob/main/LICENSE).
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# 开始
+
+在开始之前,请确保您已经安装了 Typst 环境,如果没有,可以使用 [Web App](https://typst.app/) 或 VS Code 的 [Tinymist LSP](https://marketplace.visualstudio.com/items?itemName=myriad-dreamin.tinymist) 插件。
+
+要使用 Touying,您只需要在文档里加入
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
+
+这很简单,您创建了您的第一个 Touying slides,恭喜!🎉
+
+**提示:** 你可以使用 `#import "config.typ": *` 或 `#include "content.typ"` 等 Typst 语法来实现 Touying 的多文件架构。
+
+## 更复杂的例子
+
+事实上,Touying 提供了多种 slides 编写风格,实际上您也可以使用 `#slide[..]` 的写法,以获得 Touying 提供的更多更强大的功能。
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+#import "@preview/numbly:0.1.0": numbly
+#import "@preview/theorion:0.6.0": *
+#import cosmos.clouds: *
+#show: show-theorion
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ // align: horizon,
+ // config-common(handout: true),
+ config-common(frozen-counters: (theorem-counter,)), // freeze theorem counter for animation
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= Animation
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+
+== Complex Animation
+
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+
+
+== Callback Style Animation
+
+#slide(
+ repeat: 3,
+ self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+ ],
+)
+
+
+== Math Equation Animation
+
+Equation with `pause`:
+
+$
+ f(x) &= pause x^2 + 2x + 1 \
+ &= pause (x + 1)^2 \
+$
+
+#meanwhile
+
+Here, #pause we have the expression of $f(x)$.
+
+#pause
+
+By factorizing, we can obtain this result.
+
+
+== CeTZ Animation
+
+CeTZ Animation in Touying:
+
+#cetz-canvas({
+ import cetz.draw: *
+
+ rect((0, 0), (5, 5))
+
+ (pause,)
+
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+
+ (pause,)
+
+ line((0, 0), (2.5, 2.5), name: "line")
+})
+
+
+== Fletcher Animation
+
+Fletcher Animation in Touying:
+
+#fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1, 0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0, 0), `reading`, radius: 2em),
+ edge((0, 0), (0, 0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1, 0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2, 0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0, 0), (2, 0), `close()`, "-|>", bend: -40deg),
+)
+
+
+= Theorems
+
+== Prime numbers
+
+#definition[
+ A natural number is called a #highlight[_prime number_] if it is greater
+ than 1 and cannot be written as the product of two smaller natural numbers.
+]
+#example[
+ The numbers $2$, $3$, and $17$ are prime.
+ @cor_largest_prime shows that this list is not exhaustive!
+]
+
+#theorem(title: "Euclid")[
+ There are infinitely many primes.
+]
+#pagebreak(weak: true)
+#proof[
+ Suppose to the contrary that $p_1, p_2, dots, p_n$ is a finite enumeration
+ of all primes. Set $P = p_1 p_2 dots p_n$. Since $P + 1$ is not in our list,
+ it cannot be prime. Thus, some prime factor $p_j$ divides $P + 1$. Since
+ $p_j$ also divides $P$, it must divide the difference $(P + 1) - P = 1$, a
+ contradiction.
+]
+
+#corollary[
+ There is no largest prime number.
+] <cor_largest_prime>
+#corollary[
+ There are infinitely many composite numbers.
+]
+
+#theorem[
+ There are arbitrarily long stretches of composite numbers.
+]
+
+#proof[
+ For any $n > 2$, consider $
+ n! + 2, quad n! + 3, quad ..., quad n! + n
+ $
+]
+
+
+= Others
+
+== Multiple columns
+
+#cols[
+ First column.
+][
+ Second column.
+]
+
+== Multiple columns with equal height blocks
+
+#cols(columns: (1fr, 1fr), gutter: 1em)[
+ #emph-block[
+ First column with equal height: #lorem(10)
+ #lazy-v(1fr)
+ ]
+][
+ #emph-block[
+ Second column with equal height: : #lorem(15)
+ #lazy-v(1fr)
+ ]
+]
+
+
+== Multiple Pages
+
+#lorem(200)
+
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
+```
+
+Touying 提供了很多内置的主题,能够简单地编写精美的 slides,例如此处的 `#show: university-theme.with()` 可以使用 university 主题。关于主题更详细的教程,您可以参阅后面的章节。
--- /dev/null
+{
+ "label": "主题",
+ "position": 5,
+ "link": {
+ "type": "generated-index",
+ "description": "探索 Touying 内置的主题,并学习如何创建自定义主题。"
+ }
+}
--- /dev/null
+---
+sidebar_position: 5
+---
+
+# Aqua 主题
+
+这个主题由 [@pride7](https://github.com/pride7) 制作,它的美丽背景为使用 Typst 的可视化功能制作的矢量图形。
+
+
+## 初始化
+
+你可以通过下面的代码来初始化:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.aqua: *
+
+#show: aqua-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#title-slide()
+
+#outline-slide()
+```
+
+其中 `register` 接收参数:
+
+- `aspect-ratio`: 幻灯片的长宽比为 "16-9" 或 "4-3",默认为 "16-9"。
+- `header`: 显示在页眉的内容,默认为 `utils.display-current-heading()`,也可以传入形如 `self => self.info.title` 的函数。
+- `footer`: 展示在页脚右侧的内容,默认为 `context utils.slide-counter.display()`。
+
+并且 Aqua 主题会提供一个 `#alert[..]` 函数,你可以通过 `#show strong: alert` 来使用 `*alert text*` 语法。
+
+## 颜色主题
+
+Aqua 默认使用了
+
+```typst
+config-colors(
+ primary: rgb("#003F88"),
+ primary-light: rgb("#2159A5"),
+ primary-lightest: rgb("#F2F4F8"),
+ neutral-lightest: rgb("#FFFFFF"),
+)
+```
+
+颜色主题,你可以通过 `config-colors()` 对其进行修改。
+
+## slide 函数族
+
+Aqua 主题提供了一系列自定义 slide 函数:
+
+```typst
+#title-slide(..args)
+```
+
+`title-slide` 会读取 `self.info` 里的信息用于显示。
+
+---
+
+```typst
+#let outline-slide(self: none, enum-args: (:), leading: 50pt)
+```
+
+显示一个大纲页。
+
+---
+
+```typst
+#slide(
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+ // Aqua theme
+ title: auto,
+)[
+ ...
+]
+```
+默认拥有标题和页脚的普通 slide 函数,其中 `title` 默认为当前 section title。
+
+---
+
+```typst
+#focus-slide[
+ ...
+]
+```
+用于引起观众的注意力。背景色为 `self.colors.primary`。
+
+---
+
+```typst
+#new-section-slide(title)
+```
+用给定标题开启一个新的 section。
+
+
+## 示例
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.aqua: *
+
+#show: aqua-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#title-slide()
+
+#outline-slide()
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+
+#focus-slide[
+ Another variant with primary color in background...
+]
+
+== Summary
+
+#slide(self => [
+ #align(center + horizon)[
+ #set text(size: 3em, weight: "bold", fill: self.colors.primary)
+ THANKS FOR ALL
+ ]
+])
+```
+
--- /dev/null
+---
+sidebar_position: 7
+---
+
+# 定制主题
+
+如果内置主题都不完全符合你的需求,有两种方案可供选择:
+
+1. **扩展现有主题** — 将主题文件复制到本地并进行修改。
+2. **从头构建新主题** — 实现你自己的 `xxx-theme` 函数。
+
+这两种方式均在[构建你自己的主题](../tutorials/build-your-own-theme.md)教程中有详细介绍。
+
+## 快速调整
+
+对于对现有主题的微小调整,你不需要创建单独的主题文件。可以直接内联覆盖各项设置:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ // Override the primary color
+ config-colors(primary: rgb("#1a6b8a")),
+ // Change the footer content
+ footer: self => self.info.author,
+ config-info(
+ title: [My Presentation],
+ author: [Author Name],
+ date: datetime.today(),
+ ),
+)
+
+#title-slide()
+
+= Section
+
+== Slide
+
+Content with the custom color.
+```
+
+## 将主题复制到本地
+
+若需要进行更深层的结构性修改,可以将主题源文件复制到项目中:
+
+1. 从 Touying 仓库的 `themes/` 目录下载对应文件(例如 `themes/metropolis.typ`)。
+2. 将文件顶部的导入从 `#import "../src/exports.typ": *` 改为 `#import "@preview/touying:0.7.3": *`。
+3. 在项目中导入本地副本,而不是内置主题。
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import "metropolis.typ": * // your local copy
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-info(title: [Title]),
+)
+```
+
+现在你可以自由编辑 `metropolis.typ`,而不会影响其他项目。
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# Dewdrop 主题
+
+这个主题的灵感来自 Zhibo Wang 创作的 [BeamerTheme](https://github.com/zbowang/BeamerTheme),由 [OrangeX4](https://github.com/OrangeX4) 改造而来。
+
+这个主题拥有优雅美观的 navigation,包括 `sidebar` 和 `mini-slides` 两种模式。
+
+## 初始化
+
+你可以通过下面的代码来初始化:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ navigation: "mini-slides",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ ),
+)
+
+#title-slide()
+
+#outline-slide()
+```
+
+其中 `register` 接收参数:
+
+- `aspect-ratio`: 幻灯片的长宽比为 "16-9" 或 "4-3",默认为 "16-9"。
+- `navigation`: 导航栏样式,可以是 `"sidebar"`、`"mini-slides"` 和 `none`,默认为 `"sidebar"`。
+- `sidebar`: 侧边导航栏设置,默认为 `(width: 10em, filled: false, numbered: false, indent: .5em, short-heading: true)`。
+- `mini-slides`: mini-slides 设置,默认为 `(height: 4em, x: 2em, display-section: false, display-subsection: true, short-heading: true)`。
+ - `height`: mini-slides 高度,默认为 `2em`。
+ - `x`: mini-slides 的 x 轴 padding,默认为 `2em`。
+ - `section`: 是否显示 section 之后,subsection 之前的 slides,默认为 `false`。
+ - `subsection`: 是否根据 subsection 分割 mini-slides,设置为 `false` 挤压为一行,默认为 `true`。
+- `footer`: 展示在页脚的内容,默认为 `[]`,也可以传入形如 `self => self.info.author` 的函数。
+- `footer-right`: 展示在页脚右侧的内容,默认为 `context utils.slide-counter.display() + " / " + utils.last-slide-number`。
+- `primary`: primary 颜色,默认为 `rgb("#0c4842")`。
+- `alpha`: 透明度,默认为 `70%`。
+
+并且 Dewdrop 主题会提供一个 `#alert[..]` 函数,你可以通过 `#show strong: alert` 来使用 `*alert text*` 语法。
+
+## 颜色主题
+
+Dewdrop 默认使用了
+
+```typc
+config-colors(
+ neutral-darkest: rgb("#000000"),
+ neutral-dark: rgb("#202020"),
+ neutral-light: rgb("#f3f3f3"),
+ neutral-lightest: rgb("#ffffff"),
+ primary: primary,
+)
+```
+
+颜色主题,你可以通过 `config-colors()` 对其进行修改。
+
+## slide 函数族
+
+Dewdrop 主题提供了一系列自定义 slide 函数:
+
+```typst
+#title-slide(extra: none, ..args)
+```
+
+`title-slide` 会读取 `self.info` 里的信息用于显示,你也可以为其传入 `extra` 参数,显示额外的信息。
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+)[
+ ...
+]
+```
+默认拥有导航栏和页脚的普通 slide 函数,页脚为您设置的页脚。
+
+---
+
+```typst
+#focus-slide[
+ ...
+]
+```
+用于引起观众的注意力。背景色为 `self.colors.primary`。
+
+## 示例
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ navigation: "mini-slides",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+$ x_(n+1) = (x_n + a/x_n) / 2 $
+
+== Subsection A.2
+
+A slide without a title but with *important* infos
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
+```
+
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# Metropolis 主题
+
+
+这个主题的灵感来自 Matthias Vogelgesang 创作的 [Metropolis beamer](https://github.com/matze/mtheme) 主题,由 [Enivex](https://github.com/Enivex) 改造而来。
+
+这个主题美观大方,很适合日常使用,并且你最好在电脑上安装 Fira Sans 和 Fira Math 字体,以取得最佳效果。
+
+
+## 初始化
+
+你可以通过下面的代码来初始化:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.city,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+```
+
+其中 `metropolis-theme` 接收参数:
+
+- `aspect-ratio`: 幻灯片的长宽比为 "16-9" 或 "4-3",默认为 "16-9"。
+- `align`: 幻灯片的对齐方式,默认为 `horizon`。
+- `header`: 显示在页眉的内容,默认为 `utils.display-current-heading(setting: utils.fit-to-width.with(grow: false, 100%))`,也可以传入形如 `self => self.info.title` 的函数。
+- `header-right`: 展示在页眉右侧的内容,默认为 `self => self.info.logo`。
+- `footer`: 展示在页脚的内容,默认为 `[]`,也可以传入形如 `self => self.info.author` 的函数。
+- `footer-right`: 展示在页脚右侧的内容,默认为 `context utils.slide-counter.display() + " / " + utils.last-slide-number`。
+- `footer-progress`: 是否显示 slide 底部的进度条,默认为 `true`。
+
+并且 Metropolis 主题会提供一个 `#alert[..]` 函数,你可以通过 `#show strong: alert` 来使用 `*alert text*` 语法。
+
+## 颜色主题
+
+Metropolis 默认使用了
+
+```typc
+config-colors(
+ primary: rgb("#eb811b"),
+ primary-light: rgb("#d6c6b7"),
+ secondary: rgb("#23373b"),
+ neutral-lightest: rgb("#fafafa"),
+ neutral-dark: rgb("#23373b"),
+ neutral-darkest: rgb("#23373b"),
+)
+```
+
+颜色主题,你可以通过 `config-colors()` 对其进行修改。
+
+## slide 函数族
+
+Metropolis 主题提供了一系列自定义 slide 函数:
+
+```typst
+#title-slide(extra: none, ..args)
+```
+
+`title-slide` 会读取 `self.info` 里的信息用于显示,你也可以为其传入 `extra` 参数,显示额外的信息。
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+ // metropolis theme
+ title: auto,
+ footer: auto,
+ align: horizon,
+)[
+ ...
+]
+```
+默认拥有标题和页脚的普通 slide 函数,其中 `title` 默认为当前 section title,页脚为您设置的页脚。
+
+---
+
+```typst
+#focus-slide[
+ ...
+]
+```
+用于引起观众的注意力。背景色为 `self.colors.primary-dark`。
+
+---
+
+```typst
+#new-section-slide(short-title: auto, title)
+```
+用给定标题开启一个新的 section。
+
+
+## 示例
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.metropolis: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.city,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+= Outline <touying:hidden>
+
+#outline(title: none, indent: 1em, depth: 1)
+
+= First Section
+
+---
+
+A slide without a title but with some *important* information.
+
+== A long long long long long long long long long long long long long long long long long long long long long long long long Title
+
+=== sdfsdf
+
+A slide with equation:
+
+$ x_(n+1) = (x_n + a/x_n) / 2 $
+
+#lorem(200)
+
+= Second Section
+
+#focus-slide[
+ Wake up!
+]
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+#show: appendix
+
+= Appendix
+
+---
+
+Please pay attention to the current slide number.
+```
+
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Simple 主题
+
+这个主题来源于 [Polylux](https://polylux.dev/book/themes/gallery/simple.html),作者是 Andreas Kröpelin。
+
+这个主题被认为是一个相对简单的主题,你可以用它来创建一个简单 slides,并且可以随意加入你喜欢的功能。
+
+
+## 初始化
+
+你可以通过下面的代码来初始化:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ footer: [Simple slides],
+)
+```
+
+其中 `register` 接收参数:
+
+- `aspect-ratio`: 幻灯片的长宽比为 "16-9" 或 "4-3",默认为 "16-9"。
+- `header`: 显示在页眉的内容,默认为 `utils.display-current-heading(setting: utils.fit-to-width.with(grow: false, 100%))`,也可以传入形如 `self => self.info.title` 的函数。
+- `header-right`: 展示在页眉右侧的内容,默认为 `self => self.info.logo`。
+- `footer`: 展示在页脚的内容,默认为 `[]`,也可以传入形如 `self => self.info.author` 的函数。
+- `footer-right`: 展示在页脚右侧的内容,默认为 `context utils.slide-counter.display() + " / " + utils.last-slide-number`。
+- `primary`: 主题颜色,默认为 `aqua.darken(50%)`。
+- `subslide-preamble`: 默认往当前 slide 加入 subsection 的标题。
+
+
+## slide 函数族
+
+simple 主题提供了一系列自定义 slide 函数:
+
+```typst
+#centered-slide(section: ..)[
+ ...
+]
+```
+内容位于幻灯片中央的幻灯片,`section` 参数可以用于新建一个 section。
+
+---
+
+```typst
+#title-slide[
+ ...
+]
+```
+
+和 `centered-slide` 相同,这里只是为了保持和 Polylux 语法上的一致性。
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+)[
+ ...
+]
+```
+默认拥有页眉和页脚的普通 slide 函数,其中页眉为当前 section,页脚为您设置的页脚。
+
+---
+
+```typst
+#focus-slide(foreground: ..., background: ...)[
+ ...
+]
+```
+用于引起观众的注意力。可选接受一个前景色 (默认为 `white`) 和一个背景色 (默认为 `auto`,即 `self.colors.primary`)。
+
+
+## 示例
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ footer: [Simple slides],
+)
+
+#title-slide[
+ = Keep it simple!
+ #v(2em)
+
+ Alpha #footnote[Uni Augsburg] #h(1em)
+ Bravo #footnote[Uni Bayreuth] #h(1em)
+ Charlie #footnote[Uni Chemnitz] #h(1em)
+
+ July 23
+]
+
+== First slide
+
+#lorem(20)
+
+#focus-slide[
+ _Focus!_
+
+ This is very important.
+]
+
+= Let's start a new section!
+
+== Dynamic slide
+
+Did you know that...
+
+#pause
+
+...you can see the current section at the top of the slide?
+```
+
--- /dev/null
+---
+sidebar_position: 6
+---
+
+# Stargazer 主题
+
+这个主题原本来自 [Coekjan](https://github.com/Coekjan/) 创作的 [touying-buaa](https://github.com/Coekjan/touying-buaa) 主题,美观大方,很适合日常使用。
+
+
+## 初始化
+
+你可以通过下面的代码来初始化:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.stargazer: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: stargazer-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Stargazer in Touying: Customize Your Slide Title Here],
+ subtitle: [Customize Your Slide Subtitle Here],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+```
+
+其中 `stargazer-theme` 接收参数:
+
+- `aspect-ratio`: 幻灯片的长宽比为 "16-9" 或 "4-3",默认为 "16-9"。
+- `align`: 幻灯片的对齐方式,默认为 `horizon`。
+- `alpha`: 幻灯片的透明度,默认为 `20%`。
+- `title`: 显示在页眉的内容,默认为 `utils.display-current-heading()`,也可以传入形如 `self => self.info.title` 的函数。
+- `progress-bar`: 是否显示 slide 底部的进度条,默认为 `true`。
+- `footer-columns`: 底部三栏 Footer 的宽度,默认为 `(25%, 25%, 1fr, 5em)`。
+- `footer-a`: 第一栏,默认为 `self => self.info.author`。
+- `footer-b`: 第二栏,默认为 `self => utils.display-info-date(self)`。
+- `footer-c`: 第三栏,默认为 `self => if self.info.short-title == auto { self.info.title } else { self.info.short-title }`。
+- `footer-d`: 第四栏,默认为 `context utils.slide-counter.display() + " / " + utils.last-slide-number`。
+
+## 颜色主题
+
+Stargazer 默认使用了
+
+```typc
+config-colors(
+ primary: rgb("#005bac"),
+ primary-dark: rgb("#004078"),
+ secondary: rgb("#ffffff"),
+ tertiary: rgb("#005bac"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+)
+```
+
+颜色主题,你可以通过 `config-colors()` 对其进行修改。
+
+## slide 函数族
+
+Stargazer 主题提供了一系列自定义 slide 函数:
+
+```typst
+#title-slide(extra: none, ..args)
+```
+
+`title-slide` 会读取 `self.info` 里的信息用于显示,你也可以为其传入 `extra` 参数,显示额外的信息。
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+ // stargazer theme
+ title: auto,
+ footer: auto,
+ align: horizon,
+)[
+ ...
+]
+```
+默认拥有标题和页脚的普通 slide 函数,其中 `title` 默认为当前 section title,页脚为您设置的页脚。
+
+---
+
+```typst
+#outline-slide[
+ ...
+]
+```
+用于加入大纲页。
+
+---
+
+```typst
+#focus-slide[
+ ...
+]
+```
+用于引起观众的注意力。背景色为 `self.colors.primary-dark`。
+
+---
+
+```typst
+#new-section-slide(short-title: auto, title)
+```
+用给定标题开启一个新的 section。
+
+
+## 示例
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.stargazer: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: stargazer-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Stargazer in Touying: Customize Your Slide Title Here],
+ subtitle: [Customize Your Slide Subtitle Here],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+#tblock(title: [Theorem])[
+ A simple theorem.
+
+ $ x_(n+1) = (x_n + a / x_n) / 2 $
+]
+
+== Subsection A.2
+
+A slide without a title but with *important* information.
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
+```
+
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# University 主题
+
+这个美观的主题来自 [Pol Dellaiera](https://github.com/drupol)。
+
+## 初始化
+
+你可以通过下面的代码来初始化:
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+```
+
+其中 `register` 接收参数:
+
+- `aspect-ratio`: 幻灯片的长宽比为 "16-9" 或 "4-3",默认为 "16-9"。
+- `progress-bar`: 是否显示 slide 顶部的进度条,默认为 `true`。
+- `header`: 显示在页眉的内容,默认为 `utils.display-current-heading(level: 2)`,也可以传入形如 `self => self.info.title` 的函数。
+- `header-right`: 展示在页眉右侧的内容,默认为 `self => self.info.logo`。
+- `footer-columns`: 底部三栏 Footer 的宽度,默认为 `(25%, 1fr, 25%)`。
+- `footer-a`: 第一栏,默认为 `self => self.info.author`。
+- `footer-b`: 第二栏,默认为 `self => if self.info.short-title == auto { self.info.title } else { self.info.short-title }`。
+- `footer-c`: 第三栏,默认为
+
+```typst
+self => {
+ h(1fr)
+ utils.display-info-date(self)
+ h(1fr)
+ context utils.slide-counter.display() + " / " + utils.last-slide-number
+ h(1fr)
+}
+```
+
+## 颜色主题
+
+University 默认使用了
+
+```typc
+config-colors(
+ primary: rgb("#04364A"),
+ secondary: rgb("#176B87"),
+ tertiary: rgb("#448C95"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+)
+```
+
+颜色主题,你可以通过 `config-colors()` 对其进行修改。
+
+## slide 函数族
+
+University 主题提供了一系列自定义 slide 函数:
+
+```typst
+#title-slide(logo: none, authors: none, ..args)
+```
+
+`title-slide` 会读取 `self.info` 里的信息用于显示,你也可以为其传入 `logo` 参数和 array 类型的 `authors` 参数。
+
+---
+
+```typst
+#slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: cols,
+ // university theme
+ title: none,
+)[
+ ...
+]
+```
+默认拥有标题和页脚的普通 slide 函数,其中 `title` 默认为当前 section title,页脚为您设置的页脚。
+
+### Focus Slide
+
+```typst
+#focus-slide(background-img: ..., background-color: ...)[
+ ...
+]
+```
+
+用于引起观众的注意力。默认背景色为 `self.colors.primary`。
+
+### Matrix Slide
+
+```typst
+#matrix-slide(columns: ..., rows: ...)[
+ ...
+][
+ ...
+]
+```
+可以参考 [文档](https://polylux.dev/book/themes/gallery/university.html)。
+
+
+## 示例
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide(authors: ([Author A], [Author B]))
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+
+#focus-slide[
+ Another variant with primary color in background...
+]
+
+#matrix-slide[
+ left
+][
+ middle
+][
+ right
+]
+
+#matrix-slide(columns: 1)[
+ top
+][
+ bottom
+]
+
+#matrix-slide(columns: (1fr, 2fr, 1fr), ..(lorem(8),) * 9)
+```
+
--- /dev/null
+{
+ "label": "教程",
+ "position": 3,
+ "link": {
+ "type": "generated-index",
+ "description": "循序渐进的教程,涵盖 Touying 的主要功能。"
+ }
+}
--- /dev/null
+---
+sidebar_position: 9
+---
+
+# 创建自己的主题
+
+使用 Touying 创建一个自己的主题是一件略显复杂的事情,因为我们引入了许多的概念。不过请放心,如果您真的用 Touying 创建了一个自己的主题,也许您就可以深切地感受到 Touying 提供的便利的功能的和强大的可定制性。您可以参考 [主题的源代码](https://github.com/touying-typ/touying/tree/main/themes),主要需要实现的就是:
+
+- 自定义 `xxx-theme` 函数;
+- 自定义颜色主题,即 `config-colors()`;
+- 自定义 header;
+- 自定义 footer;
+- 自定义 `slide` 方法;
+- 自定义特殊 slide 方法,如 `title-slide` 和 `focus-slide` 方法;
+
+为了演示如何使用 Touying 创建一个自己的主题,我们不妨来一步一步地创建一个简洁美观的 Bamboo 主题。
+
+
+## 修改已有主题
+
+如果你想在本地修改一个 Touying 内部的 themes,而不是自己从零开始创建,你可以选择通过下面的方式实现:
+
+1. 将 `themes` 目录下的 [主题代码](https://github.com/touying-typ/touying/tree/main/themes) 复制到本地,例如将 `themes/university.typ` 复制到本地 `university.typ` 中。
+2. 将 `university.typ` 文件顶部的 `#import "../src/exports.typ": *` 命令替换为 `#import "@preview/touying:0.7.3": *`
+
+然后就可以通过
+
+```typst
+#import "@preview/touying:0.7.3": *
+#import "university.typ": *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+```
+
+的方式导入和使用主题了。
+
+
+## 导入
+
+取决于这个主题是你自己的,还是 Touying 的一部分,你可以用两种方式导入:
+
+如果只是你自己使用,你可以直接导入 Touying:
+
+```typst
+#import "@preview/touying:0.7.3": *
+```
+
+如果你希望这个主题作为 Touying 的一部分,放置在 Touying `themes` 目录下,那你应该将上面的导入语句改为
+
+```typst
+#import "../src/exports.typ": *
+```
+
+并且要在 Touying 的 `themes/themes.typ` 里加上
+
+```typst
+#import "bamboo.typ"
+```
+
+
+## register 函数和 init 方法
+
+接下来,我们会区分 `bamboo.typ` 模板文件和 `main.typ` 文件,后者有时会被省略。
+
+一般而言,我们制作 slides 的第一步,就是确定好字体大小和页面长宽比,因此我们需要注册一个初始化方法:
+
+```example
+// bamboo.typ
+#import "@preview/touying:0.7.3": *
+
+#let bamboo-theme(
+ aspect-ratio: "16-9",
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(paper: "presentation-" + aspect-ratio),
+ config-common(
+ slide-fn: slide,
+ ),
+ ..args,
+ )
+
+ body
+}
+
+// main.typ
+<<< #import "@preview/touying:0.7.3": *
+<<< #import "bamboo.typ": *
+
+#show: bamboo-theme.with(aspect-ratio: "16-9")
+
+= First Section
+
+== First Slide
+
+A slide with a title and an *important* information.
+```
+
+如您所见,我们创建了一个 `bamboo-theme` 函数,并传入了一个 `aspect-ratio` 参数来设定页面长宽比。我们还加上了 `set text(size: 20pt)` 来设置文字大小。你也可以在这里放置一些额外的全局样式设置,例如 `set par(justify: true)` 等。如果你需要使用到 `self`,你可以考虑使用 `config-methods(init: (self: none, body) => { .. })` 来注册一个 `init` 方法。
+
+如您所见,后续在 `main.typ` 中,我们会通过 `#show: bamboo-theme.with(aspect-ratio: "16-9")` 来应用我们的样式设置,而 `bamboo` 内部又是使用 `show: touying-slides.with()` 进行了对应的配置。
+
+
+## 颜色主题
+
+为您的 slides 挑选一个美观的颜色主题,是做好一个 slides 的关键所在。Touying 提供了内置的颜色主题支持,以尽量抹平不同主题之间的 API 差异。Touying 提供了两个维度的颜色选择,第一个维度是 `neutral`、`primary`、`secondary` 和 `tertiary`,用于区分色调,其中最常用的就是 `primary` 主题色;第二个维度是 `default`、`light`、`lighter`、`lightest`、`dark`、`darker`、`darkest`,用于区分明度。
+
+由于我们是 Bamboo 主题,因此这里的主题色 `primary` 我们挑选了一个与竹子相近的颜色 `rgb("#5E8B65")`,并加入了中性色 `neutral-lightest`,`neutral-darkest`,分别作为背景色和字体颜色。
+
+正如下面的代码所示,我们可以使用 `config-colors()` 方法修改颜色主题。其本质就是 `self.colors += (..)` 的一个包装。
+
+```typst
+#let bamboo-theme(
+ aspect-ratio: "16-9",
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(paper: "presentation-" + aspect-ratio),
+ config-common(
+ slide-fn: slide,
+ ),
+ config-colors(
+ primary: rgb("#5E8B65"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ ),
+ ..args,
+ )
+
+ body
+}
+```
+
+像这样添加了颜色主题后,我们就可以通过 `self.colors.primary` 这样的方式获取到这个颜色。
+
+并且有一点值得注意,用户可以随时在 `main.typ` 里通过 `config-colors()` 或
+
+```typst
+#show: touying-set-config.with(config-colors(
+ primary: blue,
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+))
+```
+
+这种随时更换颜色主题的功能,正是 Touying 强大可定制性的体现。
+
+
+## 自定义 Alert 方法
+
+一般而言,我们都需要提供一个 `#alert[..]` 函数给用户使用,其用途与 `#strong[..]` 类似,都是用于强调当前文本。一般 `#alert[..]` 会将文本颜色修改为主题色,这样看起来会更美观,这也是我们接下来要实现的目标。
+
+我们在 `register` 函数里加上一句
+
+```typst
+config-methods(alert: (self: none, it) => text(fill: self.colors.primary, it))
+```
+
+这句代码的意思就是将文本颜色修改为 `self.colors.primary`,而这里的 `self` 正是通过参数 `self: none` 传进来的,这样我们才能实时地获取到 `primary` 主题色。
+
+我们也可以简单地使用简写。
+
+```typst
+config-methods(alert: utils.alert-with-primary-color)
+```
+
+
+## 自定义 Header 和 Footer
+
+在这里,我认为您已经阅读过页面布局章节了,因此我们知道应该给 slides 加上 header 和 footer。
+
+首先,我们先加入 `config-store(title: none)`,也就是说,我们将当前 slide 的标题作为一个成员变量 `self.store.title`,保存在 `self` 里面,这样方便我们在 header 里使用,以及后续修改。同理,我们还创建了一个 `config-store(footer: footer)`,并将 `bamboo-theme` 函数的 `footer: none` 参数保存起来,用作左下角的 footer 展示。
+
+然后值得注意的就是,我们的 header 其实是一个形如 `let header(self) = { .. }` 的参数为 `self` 的 content 函数,而不是一个单纯的 content,这样我们才能从最新的 `self` 内部获取到我们需要的信息,例如 `self.store.title`。而 footer 也是同理。
+
+里面使用到的 `components.cell` 其实就是 `#let cell = block.with(width: 100%, height: 100%, above: 0pt, below: 0pt, breakable: false)`,而 `show: components.cell` 也就是 `components.cell(body)` 的简写,footer 的 `show: pad.with(.4em)` 也是同理。
+
+另一点值得注意的是,`utils` 模块里放置了很多和计数器、状态有关的内容和方法,例如 `utils.display-current-heading(level: 1)` 用于显示当前的 `section`,而 `context utils.slide-counter.display() + " / " + utils.last-slide-number` 用于显示当前页数和总页数。
+
+以及我们发现我们会使用 `utils.call-or-display(self, self.store.footer)` 这样的语法来显示 `self.store.footer`,这是用于应付 `self.store.footer = self => {..}` 这种情况,这样我们就能统一 content 函数和 content 的显示。
+
+为了让 header 和 footer 正确显示,并且与正文有足够的间隔,我们需要设置 margin,如 `config-page(margin: (top: 4em, bottom: 1.5em, x: 2em))`。
+
+而我们还需要自定义一个 `slide` 方法,其中接收 `#let slide(title: auto, ..args) = touying-slide-wrapper(self => {..})`,回调函数中 `self` 是回调函数所必须的参数,用于获取最新的 `self`;而第二个 `title` 则是用于更新 `self.store.title`,以便在 header 中显示出来;第三个 `..args` 是用于收集剩余的参数,并传到 `touying-slide(self: self, ..args)` 里,这也是让 Touying `slide` 功能正常生效所必须的。并且,我们需要在 `bamboo-theme` 函数里使用 `config-methods(slide: slide)` 注册这个方法。
+```example
+// bamboo.typ
+#import "@preview/touying:0.7.3": *
+
+#let slide(title: auto, ..args) = touying-slide-wrapper(self => {
+ if title != auto {
+ self.store.title = title
+ }
+ // set page
+ let header(self) = {
+ set align(top)
+ show: components.cell.with(fill: self.colors.primary, inset: 1em)
+ set align(horizon)
+ set text(fill: self.colors.neutral-lightest, size: .7em)
+ utils.display-current-heading(level: 1)
+ linebreak()
+ set text(size: 1.5em)
+ if self.store.title != none {
+ utils.call-or-display(self, self.store.title)
+ } else {
+ utils.display-current-heading(level: 2)
+ }
+ }
+ let footer(self) = {
+ set align(bottom)
+ show: pad.with(.4em)
+ set text(fill: self.colors.neutral-darkest, size: .8em)
+ utils.call-or-display(self, self.store.footer)
+ h(1fr)
+ context utils.slide-counter.display() + " / " + utils.last-slide-number
+ }
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ header: header,
+ footer: footer,
+ ),
+ )
+ touying-slide(self: self, ..args)
+})
+
+#let title-slide(..args) = touying-slide-wrapper(self => {
+ let info = self.info + args.named()
+ let body = {
+ set align(center + horizon)
+ block(
+ fill: self.colors.primary,
+ width: 80%,
+ inset: (y: 1em),
+ radius: 1em,
+ text(size: 2em, fill: self.colors.neutral-lightest, weight: "bold", info.title),
+ )
+ set text(fill: self.colors.neutral-darkest)
+ if info.author != none {
+ block(info.author)
+ }
+ if info.date != none {
+ block(utils.display-info-date(self))
+ }
+ if info.contact != none {
+ block(info.contact)
+ }
+ }
+ touying-slide(self: self, body)
+})
+
+#let new-section-slide(self: none, body) = touying-slide-wrapper(self => {
+ let main-body = {
+ set align(center + horizon)
+ set text(size: 2em, fill: self.colors.primary, weight: "bold", style: "italic")
+ utils.display-current-heading(level: 1)
+ }
+ touying-slide(self: self, main-body)
+})
+
+#let focus-slide(body) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ fill: self.colors.primary,
+ margin: 2em,
+ ),
+ )
+ set text(fill: self.colors.neutral-lightest, size: 2em)
+ touying-slide(self: self, align(horizon + center, body))
+})
+
+#let bamboo-theme(
+ aspect-ratio: "16-9",
+ footer: none,
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(
+ paper: "presentation-" + aspect-ratio,
+ margin: (top: 4em, bottom: 1.5em, x: 2em),
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ ),
+ config-methods(alert: utils.alert-with-primary-color),
+ config-colors(
+ primary: rgb("#5E8B65"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ ),
+ config-store(
+ title: none,
+ footer: footer,
+ ),
+ ..args,
+ )
+
+ body
+}
+
+// main.typ
+<<< #import "@preview/touying:0.7.3": *
+<<< #import "bamboo.typ": *
+
+#show: bamboo-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ ),
+)
+
+#title-slide()
+
+= First Section
+
+== First Slide
+
+A slide with a title and an *important* information.
+
+#focus-slide[
+ Focus on it!
+]
+```
+
+
+## 自定义特殊 Slide
+
+我们在上面的基础 slide 的基础上,进一步加入一些特殊的 slide 函数,例如 `title-slide`,`focus-slide` 以及自定义 `slides` 方法。
+
+对于 `title-slide` 方法,首先,我们可以通过 `let info = self.info + args.named()` 获取到 `self.info` 里保存的信息,也可以用函数参数里传入的 `args.named()` 来更新信息,便于后续以 `info.title` 的方式使用。具体的页面内容 `body`,每个 theme 都会有所不同,这里就不再过多赘述。
+
+对于 `new-section-slide` 方法,也是同理,不过唯一要注意的是我们在 `config-methods()` 中注册了 `new-section-slide-fn: new-section-slide`,这样 `new-section-slide` 就会在碰到一级标题时自动被调用。
+
+```
+// bamboo.typ
+#import "@preview/touying:0.7.3": *
+
+#let slide(title: auto, ..args) = touying-slide-wrapper(self => {
+ if title != auto {
+ self.store.title = title
+ }
+ // set page
+ let header(self) = {
+ set align(top)
+ show: components.cell.with(fill: self.colors.primary, inset: 1em)
+ set align(horizon)
+ set text(fill: self.colors.neutral-lightest, size: .7em)
+ utils.display-current-heading(level: 1)
+ linebreak()
+ set text(size: 1.5em)
+ if self.store.title != none {
+ utils.call-or-display(self, self.store.title)
+ } else {
+ utils.display-current-heading(level: 2)
+ }
+ }
+ let footer(self) = {
+ set align(bottom)
+ show: pad.with(.4em)
+ set text(fill: self.colors.neutral-darkest, size: .8em)
+ utils.call-or-display(self, self.store.footer)
+ h(1fr)
+ context utils.slide-counter.display() + " / " + utils.last-slide-number
+ }
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ header: header,
+ footer: footer,
+ ),
+ )
+ touying-slide(self: self, ..args)
+})
+
+#let title-slide(..args) = touying-slide-wrapper(self => {
+ let info = self.info + args.named()
+ let body = {
+ set align(center + horizon)
+ block(
+ fill: self.colors.primary,
+ width: 80%,
+ inset: (y: 1em),
+ radius: 1em,
+ text(size: 2em, fill: self.colors.neutral-lightest, weight: "bold", info.title),
+ )
+ set text(fill: self.colors.neutral-darkest)
+ if info.author != none {
+ block(info.author)
+ }
+ if info.date != none {
+ block(utils.display-info-date(self))
+ }
+ }
+ touying-slide(self: self, body)
+})
+
+#let new-section-slide(self: none, body) = touying-slide-wrapper(self => {
+ let main-body = {
+ set align(center + horizon)
+ set text(size: 2em, fill: self.colors.primary, weight: "bold", style: "italic")
+ utils.display-current-heading(level: 1)
+ }
+ touying-slide(self: self, main-body)
+})
+
+#let focus-slide(body) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ fill: self.colors.primary,
+ margin: 2em,
+ ),
+ )
+ set text(fill: self.colors.neutral-lightest, size: 2em)
+ touying-slide(self: self, align(horizon + center, body))
+})
+
+#let bamboo-theme(
+ aspect-ratio: "16-9",
+ footer: none,
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(
+ paper: "presentation-" + aspect-ratio,
+ margin: (top: 4em, bottom: 1.5em, x: 2em),
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ ),
+ config-methods(alert: utils.alert-with-primary-color),
+ config-colors(
+ primary: rgb("#5E8B65"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ ),
+ config-store(
+ title: none,
+ footer: footer,
+ ),
+ ..args,
+ )
+
+ body
+}
+
+
+// main.typ
+<<< #import "@preview/touying:0.7.3": *
+<<< #import "bamboo.typ": *
+
+#show: bamboo-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#title-slide()
+
+= First Section
+
+== First Slide
+
+A slide with a title and an *important* information.
+
+#focus-slide[
+ Focus on it!
+]
+```
+
+
+## 总结
+
+至此,我们就已经创建了一个简洁又美观的主题了。也许你会觉得,Touying 引入的概念过于丰富了,以至于让人一时很难轻易接受。这是正常的,在强大的功能与简洁的概念之间,Touying 选择了前者。但是也正是得益于 Touying 这种大而全的统一理念,你可以很容易地在不同的主题之间抽离出共通之处,并将你学到的概念迁移到另一个主题上。亦或者,你可以很轻易地保存全局变量,或者更改已有的主题,例如全局保存主题颜色,替换掉 slides 的 header,或者添加一两个 Logo 等,这也正是 Touying 解耦的好处。
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# 代码风格
+
+## 简单风格
+
+如果我们只是需要简单使用,我们可以直接在标题下输入内容,就像是在编写正常 Typst 文档一样。这里的标题有着分割页面的作用,同时我们也能正常地使用 `#pause` 等命令实现动画效果。
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
+
+并且你可以使用空标题 `== <touying:hidden>` 创建一个新页,这个技巧也有助于清除上一个标题的继续应用。
+
+如果我们需要维持当前标题,仅仅是想加入一个新页,我们可以使用 `#pagebreak()`,亦或者直接使用 `---` 来分割页面,后者在 Touying 中被解析为 `#pagebreak()`。
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+---
+
+Hello, Typst!
+```
+
+## 块风格
+
+很多时候,仅仅使用简单风格并不能实现我们需要的所有功能,为了更强大的功能和更清晰的结构,我们同样可以使用 `#slide[...]` 形式的块风格。
+
+例如上面的例子就可以改造成
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== First Slide
+
+#slide[
+ Hello, Touying!
+
+ #pause
+
+ Hello, Typst!
+]
+```
+
+以及 `#empty-slide[]` 可以创建一个没有 header 和 footer 的空 Slide。
+
+这样做的好处有很多:
+
+1. 很多时候,我们不只是需要默认的 `#slide[...]`,还需要 `#focus-slide[...]` 这些特殊的 `slide` 函数;
+2. 不同主题的 `#slide[...]` 函数可能有比默认更多的参数,例如 metropolis 主题的 `#slide[...]` 函数就会有着 `align` 参数可以设置对齐方式;
+3. 只有 `slide` 函数才可以通过回调风格的内容块来使用 `#only` 和 `#uncover` 函数实现复杂的动画效果。
+4. 能有着更清晰的结构,通过辨别 `#slide[...]` 块,我们可以很容易地分辨出 slides 的具体分页效果。
+
+
+## 约定优于配置
+
+你可能注意到了,在使用 simple 主题时,我们使用一级标题会自动创建一个 section slide,这是因为 simple 主题注册了一个 `config-common(slide-fn: slide, new-section-slide-fn: new-section-slide)` 函数,因此 touying 会默认调用这个函数。
+
+如果我们不希望它自动创建这样一个 section slide,我们可以将这个方法删除:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(new-section-slide-fn: none),
+)
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
+
+如你所见,这样就只会剩下两页,而默认的 section slide 就会消失了。
+
+同理,我们也可以注册一个新的 section slide:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(new-section-slide-fn: section => {
+ touying-slide-wrapper(self => {
+ touying-slide(
+ self: self,
+ {
+ set align(center + horizon)
+ set text(size: 2em, fill: self.colors.primary, style: "italic", weight: "bold")
+ utils.display-current-heading(level: 1)
+ },
+ )
+ })
+ }),
+)
+
+= Title
+
+== First Slide
+
+Hello, Touying!
+
+#pause
+
+Hello, Typst!
+```
\ No newline at end of file
--- /dev/null
+{
+ "label": "动态幻灯片",
+ "position": 5,
+ "link": {
+ "type": "generated-index",
+ "description": "想要在 PDF 中创建动画,我们就需要为同一个 slide 创建多个略有不同的页面,以便通过切换页面的方式实现动画,我们称这些页面为 subslides。"
+ }
+}
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# 复杂动画
+
+得益于 [Polylux](https://polylux.dev/book/dynamic/syntax.html) 提供的语法,我们同样能够在 Touying 中使用 `only`、`uncover` 和 `alternatives`。
+
+
+## 标记风格的函数
+
+我们可以使用标记风格的函数,用起来十分方便。
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+```
+
+但是这种方式并非在所有情况下都能生效,例如你将 `uncover` 放入 `context` 表达式中,就会报错。
+
+
+## 回调风格的函数
+
+为了避免上文提到的布局函数的限制,Touying 利用回调函数巧妙实现了总是能生效的 `only`、`uncover` 和 `alternatives`,具体来说,您要这样引入这三个函数:
+
+```example
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+])
+```
+
+注意到了吗?我们不再是传入一个内容块,而是传入了一个参数为 `self` 的回调函数,随后我们通过
+
+```typst
+#let (uncover, only, alternatives) = utils.methods(self)
+```
+
+从 `self` 中取出了 `only`、`uncover` 和 `alternatives` 这三个函数,并在后续调用它们。
+
+这里还有一些有趣的事实,例如 int 类型的 `self.subslide` 指示了当前 subslide 索引,而实际上 `only`、`uncover` 和 `alternatives` 函数也正是依赖 `self.subslide` 实现的获取当前 subslide 索引。
+
+:::warning[警告]
+
+我们手动指定了参数 `repeat: 3`,这代表着显示 3 张 subslides,我们需要手动指定是因为 Touying 无法探知回调风格 `only`、`uncover` 和 `alternatives` 需要显示多少张 subslides。
+
+:::
+
+## only
+
+`only` 函数表示只在选定的 subslides 中「出现」,如果不出现,则会完全消失,也不会占据任何空间。也即 `#only(index, body)` 要么为 `body` 要么为 `none`。
+
+其中 index 可以是 int 类型,也可以是 `"2-"` 或 `"2-3"` 这样的 str 类型,更多用法可以参考 [Polylux](https://polylux.dev/book/dynamic/complex.html)。
+
+为方便使用,我们还支持 `auto`,它使用遇到 `only` 时的当前 subslide 位置;`"h"` 也是如此,但它是字符串,以及其派生形式:`"h-"` 和 `"-h"`。
+此外,我们还允许通过 `"!"` 进行反转。只需写 `"!h"` 或 `"!2-4"` 即可获取除这些 subslides 外的所有 subslides。与正常索引相反,反转不会增加 subslide 计数。
+
+关于如何使用路标,请参阅关于[路标](./waypoints.md)的专门章节。
+
+## uncover
+
+`uncover` 函数表示只在选定的 subslides 中「显示」,否则会被 `cover` 函数遮挡,但仍会占据原有。也即 `#uncover(index, body)` 要么为 `body` 要么为 `cover(body)`。
+
+其中 index 可以是 int 类型,也可以是 `"2-"` 或 `"2-3"` 这样的 str 类型,更多用法可以参考 [Polylux](https://polylux.dev/book/dynamic/complex.html)。\
+但您也可以使用上面为 `only` 展示的其他选项。
+
+您应该也注意到了,事实上 `#pause` 也使用了 `cover` 函数,只是提供了更便利的写法,实际上它们的效果基本上是一致的。
+
+
+## alternatives
+
+`alternatives` 函数表示在不同的 subslides 中展示一系列不同的内容,例如
+
+```example
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ #alternatives[Ann][Bob][Christopher]
+ likes
+ #alternatives[chocolate][strawberry][vanilla]
+ ice cream.
+])
+```
+
+如你所见,`alternatives` 能够自动撑开到最合适的宽度和高度,这是 `only` 和 `uncover` 所没有的能力。事实上 `alternatives` 还有着其他参数,例如 `start: 2`、`repeat-last: true` 和 `position: center + horizon` 等,更多用法可以参考 [Polylux](https://polylux.dev/book/dynamic/alternatives.html)。
+
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# Cover 函数
+
+正如您已经了解的那样,`uncover` 和 `#pause` 均会使用 `cover` 函数对不显示的内容进行遮盖。那么,这里的 `cover` 函数究竟是什么呢?
+
+
+## 默认 Cover 函数:`hide`
+
+`cover` 函数是保存在 `s.methods.cover` 的一个方法,后续 `uncover` 和 `#pause` 均会在这里取出 `cover` 函数来使用。
+
+默认的 `cover` 函数是 [hide](https://typst.app/docs/reference/layout/hide/) 函数,这个函数能将内部的内容更改为不可见的,且不会影响布局。
+
+
+## 更新 Cover 函数
+
+有的情况下,您想用您自己的 `cover` 函数,那么您可以通过
+
+```typst
+config-methods(cover: (self: none, body) => hide(body))
+```
+
+方法来设置您自己的 `cover` 函数。
+
+
+## hack: 处理 enum 和 list
+
+你会发现现有的 cover 函数无法隐藏 enum 和 list 的 mark,参考 [这里](https://github.com/touying-typ/touying/issues/10),因此你可以进行 hack:
+
+```typst
+config-methods(cover: (self: none, body) => box(scale(x: 0%, body)))
+```
+
+
+## 半透明 Cover 函数
+
+Touying 提供了半透明 Cover 函数的支持,只需要加入
+
+```typst
+config-methods(cover: utils.semi-transparent-cover.with(alpha: 85%))
+```
+
+即可开启,其中你可以通过 `alpha: ..` 参数调节透明度。
+
+
+:::warning[警告]
+
+注意,这里的 `transparent-cover` 并不能像 `hide` 一样不影响文本布局,因为里面有一层 `box`,因此可能会破坏页面原有的结构。
+
+:::
+
+
+:::tip[原理]
+
+`utils.semi-transparent-cover` 方法定义为
+
+```typst
+#let semi-transparent-cover(self: none, constructor: rgb, alpha: 85%, body) = {
+ cover-with-rect(
+ fill: update-alpha(
+ constructor: constructor,
+ self.page.fill,
+ alpha,
+ ),
+ body,
+ )
+}
+```
+
+可以看出,其是通过 `utils.cover-with-rect` 创建了一个与背景色同色的半透明矩形遮罩,以模拟内容透明的效果,其中 `constructor: rgb` 和 `alpha: 85%` 分别表明了背景色的构造函数与透明程度。
+
+:::
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# 数学公式动画
+
+Touying 还提供了一个独特且十分有用的功能,即数学公式动画,它让你可以方便地在数学公式里使用 `pause` 和 `meanwhile`。
+
+## 简单动画
+
+让我们先来看一个例子:
+
+```example
+#slide[
+ Touying equation with pause:
+
+ $
+ f(x) &= pause x^2 + 2x + 1 \
+ &= pause (x + 1)^2 \
+ $
+
+ #meanwhile
+
+ Touying equation is very simple.
+]
+```
+
+
+我们使用 `touying-equation` 函数来实现在数学公式文本内部使用 `pause` 和 `meanwhile`(事实上,你也能用 `#pause` 或者 `#pause;`)。
+
+正如你料想的一样,数学公式会分步显示,这很适合给让演讲者演示自己的数学公式推理思路。
+
+
+## 复杂动画
+
+事实上,我们也可以使用 `only`、`uncover` 和 `alternatives`:
+
+```example
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ $
+ f(x) &= pause x^2 + 2x + uncover("3-", 1) \
+ &= pause (x + 1)^2 \
+ $
+])
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 6
+---
+
+# 讲义模式
+
+讲义模式将每张逻辑幻灯片的所有动画子幻灯片合并为单页,便于生成可打印或可分发的演示文稿版本。
+
+## 启用讲义模式
+
+```typst
+config-common(handout: true)
+```
+
+将其放在主题设置中:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(handout: true),
+)
+
+= Title
+
+== Animated Slide
+
+First item.
+
+#pause
+
+Second item (won't generate a separate page in handout mode).
+
+#pause
+
+Third item.
+```
+
+默认情况下,讲义模式只保留每张幻灯片的**最后一个**子幻灯片。
+
+## 选择保留哪个子幻灯片
+
+你可以使用 `handout-subslides` 指定讲义输出中保留特定的子幻灯片:
+
+```typst
+// 只保留第一个子幻灯片(适用于"之前"快照)
+config-common(handout: true, handout-subslides: 1)
+
+// 保留第一个和最后一个子幻灯片
+config-common(handout: true, handout-subslides: (1, -1))
+
+// 用字符串表示范围(与 `only`/`uncover` 语法相同)
+config-common(handout: true, handout-subslides: "1-2")
+```
+
+## 仅在讲义中显示的幻灯片
+
+使用 `<touying:handout>` 标签创建**仅在讲义模式下**显示、在正常演示时隐藏的幻灯片:
+
+```typst
+== Extra Notes for Handout <touying:handout>
+
+This slide is included when `handout: true` but invisible otherwise.
+```
+
+## 工作流建议
+
+一种常见的工作流是:演示时保持 `handout: false`(默认值),导出分发用的 PDF 时切换为 `handout: true`:
+
+```typst
+// 演示时
+#show: my-theme.with(config-common(handout: false))
+
+// 构建讲义 PDF 时
+#show: my-theme.with(config-common(handout: true))
+```
--- /dev/null
+---
+sidebar_position: 5
+---
+
+# 其他动画
+
+Touying 还提供了 `touying-reducer`,它能让所有动画在 CeTZ 和 Fletcher 中原生工作。
+
+## 简单动画
+
+一个例子:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: node, edge
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(reduce: cetz.canvas, cover: cetz.draw.hide.with(bounds: true))
+#let fletcher-diagram = touying-reducer.with(reduce: fletcher.diagram, cover: fletcher.hide)
+
+#show: university-theme.with(aspect-ratio: "16-9")
+
+// cetz animation
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0,0), (5,5))
+
+ (pause,)
+
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+
+ (pause,)
+
+ line((0,0), (2.5, 2.5), name: "line")
+ })
+]
+
+// fletcher animation
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
+ spacing: 4em,
+ edge((-1,0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0,0), `reading`, radius: 2em),
+ edge((0,0), (0,0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1,0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2,0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0,0), (2,0), `close()`, "-|>", bend: -40deg),
+ )
+]
+```
+
+## `only`、`uncover` 和 `alternatives`
+
+事实上,我们也可以在 CeTZ 和 Fletcher 内部使用 `only`、`uncover` 甚至 `alternatives`,使用相同的语法。由于 CeTZ 和 Fletcher 通常是基于位置的,不同命令得到图表看起来是一样的,但在底层它们的工作方式不同。`only` 会丢弃绘制命令,而 `uncover` 会使用包里的 `hide` 来隐藏。
+
+```typst
+//imports, bindings and theme
+
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0,0), (5,5))
+ (pause,)
+
+ rect((0,0), (1,1))
+
+ (uncover(3, {
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+ }),)
+
+ (only(3, line((0,0), (2.5, 2.5), name: "line") ),)
+ })
+]
+
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ spacing: 4em,
+ node((0, 0), [A], radius: 2em),
+ pause,
+ uncover("1-2", edge((0, 0), (1, 0), "-|>", stroke: blue)),
+ uncover("2-", node((1, 0), [B], radius: 2em)),
+ only(3, node((0, 1), [tmp], radius: 1em, fill: orange)),
+ )
+]
+```
+
+注意,像 `effect` 和 `item-by-item` 这样的命令可能无法按预期工作。
+
+## 回调式绑定
+
+如果你不想为 CeTZ 编写数组语法 `(anim-cmd(), )`,你可以在 canvas 中通过 utils 本地重新定义你需要的命令。这样它们就会输出 CeTZ 原生理解的格式。但是,你需要通过 `repeat` 手动计算子幻灯片的数量!
+
+```example
+#import "@preview/touying:0.7.3": *
+#import "@preview/cetz:0.5.0"
+#import themes.simple: *
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ Cetz in Touying in subslide #self.subslide:
+
+ #cetz.canvas({
+ import cetz.draw: *
+ let uncover = uncover.with(cover-fn: hide.with(bounds: true))
+
+ rect((0,0), (5,5))
+
+ uncover("2-3", {
+ rect((0,0), (1,1))
+ rect((1,1), (2,2))
+ rect((2,2), (3,3))
+ })
+
+ only(3, line((0,0), (2.5, 2.5), name: "line"))
+ })
+])
+```
+
+(这也适用于 Fletcher,但实际上没有理由使用它。)
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# 简单动画
+
+Touying 为简单的动画效果提供了两个标记:`#pause` 和 `#meanwhile`。
+
+## pause
+
+`#pause` 的用途很简单,就是用于将后续的内容放到下一张 subslide 中,并且可以使用多个 `#pause` 以创建多张 subslides,一个简单的例子:
+
+```example
+#slide[
+ First #pause Second
+
+ #pause
+
+ Third
+]
+```
+
+这个例子将会创建三张 subslides,逐渐地将内容展示出来。
+
+如你所见,`#pause` 既可以放在行内,也可以放在单独的一行。
+
+
+## meanwhile
+
+有些情况下,我们需要在 `#pause` 的同时展示一些其他内容,这时候我们就可以用 `#meanwhile`。
+
+```example
+#slide[
+ First
+
+ #pause
+
+ Second
+
+ #meanwhile
+
+ Third
+
+ #pause
+
+ Fourth
+]
+```
+
+这个例子只会创建两张 subslides,并且 "First" 和 "Third" 同时显示,"Second" 和 "Fourth" 同时显示。
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 7
+---
+
+# 路标
+
+路标允许你为幻灯片动画时间线中的位置命名,并通过标签引用它们,而不是使用硬编码的 subslide 编号。这使得动画更容易维护——在路标之前插入 pause 或新项目时,后续内容会自动调整位置。再也不需要自己数 subslide 了。
+
+## 基本用法
+
+使用 `#waypoint(<label>)` 标记一个命名位置,然后在 `#uncover` 或 `#only` 中使用该标签:
+
+```typst
+#slide[
+ Base content.
+ #waypoint(<step-a>)
+ #uncover(<step-a>)[Uncovered from step-a.]
+ #waypoint(<step-b>)
+ #only(<step-b>)[Only during step-b.]
+]
+```
+
+每个前向路标(默认行为)会创建一个新的 subslide。这里 `<step-a>` 在 subslide 2 触发,`<step-b>` 在 subslide 3 触发。
+
+## 隐式路标
+
+当你直接将一个新标签传递给 `#uncover`、`#only` 或 `#item-by-item` 时,会自动生成一个隐式路标——无需单独调用 `#waypoint`:
+
+```typst
+#slide[
+ First content.
+ #uncover(<reveal>)[Appears from here.]
+ #only(<final>)[Only on the last step.]
+]
+```
+
+每个标签的隐式路标只会注册一次(首次出现生效),因此对同一标签的多次引用共享同一位置。
+
+## item-by-item 与路标
+
+`#item-by-item` 接受标签作为其 `start` 参数。项目从该路标的位置开始逐个显示:
+
+```typst
+#slide[
+ #item-by-item(start: <list>)[
+ - Alpha
+ - Beta
+ - Gamma
+ ]
+ #only(<done>)[All items revealed.]
+]
+```
+
+这将产生 4 个 subslides:项目分别出现在 2、3、4(隐式 `<list>` 路标前进到 subslide 2),`<done>` 在 subslide 5 触发。
+
+> 注意:路标会捕获其后的所有 subslides 直到下一个路标出现。
+
+## 非前向路标
+
+默认情况下,路标会推进 subslide 计数器。使用 `advance: false` 可以标记位置而不创建新的 subslide:
+
+```typst
+#slide[
+ #waypoint(<here>, advance: false)
+ Content at the current position.
+]
+```
+
+## 路标标记
+
+如需更精细的控制,可以使用路标标记(`wp-m`)来引用路标范围的特定部分:
+
+| 标记 | 含义 |
+|---|---|
+| `from-wp(<label>)` | 从路标的第一个 subslide 之后的所有 subslides。|
+| `until-wp(<label>)` | 直到路标范围最后一个 subslide 的所有 subslides。|
+| `get-first(<label>)` | 路标范围的第一个 subslide。|
+| `get-last(<label>)` | 路标范围的最后一个 subslide。|
+| `prev-wp(<label>)` | 给定路标的前一个路标。|
+| `next-wp(<label>)` | 给定路标的后一个路标。|
+| `not-wp(<label>)` | 不在路标范围内的所有 subslides。|
+
+```typst
+#slide[
+ #waypoint(<mid>)
+ #uncover(<mid>)[Visible during mid.]
+ #waypoint(<end>)
+ #uncover(from-wp(<mid>))[From mid onward.]
+ #only(prev-wp(<end>))[Only before end starts.]
+]
+```
+
+你甚至可以组合使用路标标记来指定确切的行为:
+
+```typst
+#slide[
+ #waypoint(<mid>, advance:false)
+ #uncover(<mid>)[Visible during mid.]
+ #pause
+ Second mid.
+ #waypoint(<end>)
+ End.
+
+ #only(not-wp(get-first(<mid>)))[Soon finished.]
+]
+```
+
+## 综合示例
+
+如前所述,路标会捕获其后的一系列 subslides,你也可以复用路标来引用整个范围。
+
+```typst
+#slide(composer: (1fr, 1fr))[
+ #item-by-item(start: <steps>)[
+ - Step one
+ - Step two
+ - Step three
+ ]
+ #pause
+ Some remark.
+ #uncover(<done>)[All done!]
+][
+ #alternatives(at: (<steps>, <done>))[
+ _Working through the steps..._
+ ][
+ _Complete!_
+ ]
+]
+```
+
+## 显式路标起点
+
+你还可以为路标设置显式的起点值,既可以使用 subslide 索引,也可以使用其他路标:
+
+```typst
+#slide(composer: (1fr, 1fr))[
+ #item-by-item(start: <steps>)[
+ - Step one
+ - Step two
+ - Step three
+ ]
+ #pause
+ Some remark.
+ #uncover(<done>, start: 4)[All done, even before the remark!]
+][
+ #waypoint(<parallel>, start: <done>)
+ Explaining stuff.
+ #pause
+ More explanation.
+]
+```
+
+## 更多示例
+
+有关路标功能的完整示例——包括回调风格的幻灯片、与 CeTZ 和 Fletcher 的集成、`recall-subslide` 以及边界情况——请参阅 [`examples/waypoints.typ`](https://github.com/touying-typ/touying/blob/main/examples/waypoints.typ)。
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 4
+---
+
+# 页面布局
+
+## 基础概念
+
+要想使用 Typst 制作一个样式美观的 slides,正确理解 Typst 的页面模型是必须的,如果你不关心自定义页面样式,你可以选择跳过这部分,否则还是推荐看一遍这部分。
+
+下面我们通过一个具体的例子来说明 Typst 的默认页面模型。
+
+```example
+#let container = rect.with(height: 100%, width: 100%, inset: 0pt)
+#let innerbox = rect.with(stroke: (dash: "dashed"))
+
+#set text(size: 30pt)
+#set page(
+ paper: "presentation-16-9",
+ header: container[#innerbox[Header]],
+ header-ascent: 30%,
+ footer: container[#innerbox[Footer]],
+ footer-descent: 30%,
+)
+
+#place(top + right)[Margin→]
+#container[
+ #container[
+ #innerbox[Content]
+ ]
+]
+```
+
+我们需要区分以下概念:
+
+1. **Model:** Typst 拥有与 CSS Box Model 类似的模型,分为 Margin、Padding 和 Content,但其中 padding 并非 `set page(..)` 的属性,而是我们手动添加 `#pad(..)` 得到的。
+2. **Margin:** 页边距,分为上下左右四个方向,是 Typst 页面模型的核心,其他属性都会受到页边距的影响,尤其是 Header 和 Footer。Header 和 Footer 实际上是位于 Margin 内部。
+4. **Header:** Header 是页面顶部的内容,又分为 container 和 innerbox。我们可以注意到 header container 和 padding 的边缘并不贴合,而是也有一定的空隙,这个空隙实际上就是 `header-ascent: 30%`,而这里的百分比是相对于 margin-top 而言的。并且,我们注意到 header innerbox 实际上位于 header container 左下角,也即 innerbox 实际上默认有属性 `#set align(left + bottom)`。
+5. **Footer:** Footer 是页面底部的内容,与 Header 类似,只不过方向相反。
+6. **Place:** `place` 函数可以实现绝对定位,在不影响父容器内其他元素的情况下,相对于父容器来定位,并且可以传入 `alignment`、`dx` 和 `dy`,很适合用来放置一些修饰元素,例如 Logo 之类的图片。
+
+因此,要将 Typst 应用到制作 slides 上,我们只需要设置
+
+```typst
+#set page(
+ margin: (x: 4em, y: 2em),
+ header: align(top)[Header],
+ footer: align(bottom)[Footer],
+ header-ascent: 0em,
+ footer-descent: 0em,
+)
+```
+
+即可。但是我们还需要解决 header 如何占据整个页面宽度的问题,在这里我们使用 negative padding 实现,例如我们有
+
+```example
+#let container = rect.with(stroke: (dash: "dashed"), height: 100%, width: 100%, inset: 0pt)
+#let innerbox = rect.with(fill: rgb("#d0d0d0"))
+#let margin = (x: 4em, y: 2em)
+
+// negative padding for header and footer
+#let negative-padding = pad.with(x: -margin.x, y: 0em)
+
+#set text(size: 30pt)
+#set page(
+ paper: "presentation-16-9",
+ margin: margin,
+ header: negative-padding[#container[#align(top)[#innerbox(width: 100%)[Header]]]],
+ header-ascent: 0em,
+ footer: negative-padding[#container[#align(bottom)[#innerbox(width: 100%)[Footer]]]],
+ footer-descent: 0em,
+)
+
+#place(top + right)[↑Margin→]
+#container[
+ #container[
+ #innerbox[Content]
+ ]
+]
+```
+
+## 页面管理
+
+由于 Typst 中使用 `set page(..)` 命令来修改页面参数,会导致创建一个新的页面,而不能修改当前页面,因此 Touying 选择维护一个 `self.page` 成员变量。
+
+例如,上面的例子就可以改成
+
+```typst
+#show: default-theme.with(
+ config-page(
+ margin: (x: 4em, y: 2em),
+ header: align(top)[Header],
+ footer: align(bottom)[Footer],
+ header-ascent: 0em,
+ footer-descent: 0em,
+ ),
+)
+```
+
+Touying 会自动检测 `margin.x` 的值,并且判断如果设置 `config-common(zero-margin-header: true)` 也即 `self.zero-margin-header == true`,就会自动为 header 加入负填充。
+
+同理,如果你对某个主题的 header 或 footer 样式不满意,你也可以通过
+
+```typst
+config-page(footer: [Custom Footer])
+```
+
+:::warning[警告]
+
+因此,你不应该自己使用 `set page(..)` 命令,因为会被 Touying 重置。
+
+:::
+
+借助这种方式,我们也可以通过 `self.page` 实时查询当前页面的参数,这对一些需要获取页边距或当前页面背景颜色的函数很有用,例如 `transparent-cover`。这里就部分等价于 context get rule,而且实际上用起来会更方便。
+
+
+## 页面分栏
+
+如果你需要将页面分为两栏或三栏,你可以使用 Touying `slide` 函数默认提供的 `composer` 功能,最简单的示例如下:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ First column.
+][
+ Second column.
+]
+```
+
+如果你需要更改分栏的方式,可以修改 `slide` 的 `composer` 参数,其中默认的参数是 `cols.with(columns: auto, gutter: 1em)`,如果我们要让左边那一栏占据剩余宽度,可以使用
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide(composer: (1fr, auto))[
+ First column.
+][
+ Second column.
+]
+```
+
+## 使用 `lazy-v` 对齐多栏高度
+
+使用多栏布局(通过 `cols` 或手动 `grid`)时,内容量不同的各栏高度会不一致。如果你希望在每栏底部放置一些"页脚"内容(如标签或说明文字),并让它们在各栏之间对齐,或者只是想让所有栏与最高栏的高度一致,可以配合使用 `lazy-v` 和 `lazy-layout`。
+
+### 工作原理
+
+- **`lazy-v(1fr)`** — 在 block 的主要内容和底部内容之间插入此标记。它是一个延迟生效的垂直弹性空间,在高度测量阶段不可见。
+- **`lazy-layout`** — 包裹多栏布局。它先测量所有栏的自然高度(忽略 `lazy-v` 标记),然后以该固定高度重新渲染并激活标记。这样每栏都会被拉伸到与最高栏一致的高度,同时整体容器不会撑满整个页面。
+
+### 使用 `cols`(推荐)
+
+`cols` 默认启用 `lazy-layout`,你只需要在每个 block 内添加 `lazy-v(1fr)` 即可:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#cols[
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(10)
+ #lazy-v(1fr)
+ Bottom left.
+ ]
+][
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(20)
+ #lazy-v(1fr)
+ Bottom right.
+ ]
+]
+```
+
+两栏将具有相同的高度(与较高的一栏一致),"Bottom left."和"Bottom right."会在底部对齐。整体布局高度等于最高栏的高度,**不会**撑满整个页面。
+
+:::note[注意]
+
+这与在 `#slide[][]` 中使用 `v(1fr)` 不同。`slide` 的 composer 会占据整个页面高度,因此 `v(1fr)` 可以直接生效。`lazy-v` 是为独立的 `cols` 或 `lazy-layout` 调用设计的,用于在不撑满整页的情况下实现高度对齐。
+
+:::
+
+### 使用手动 Grid
+
+你也可以直接用 `lazy-layout` 包裹一个 `grid`:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#lazy-layout(grid(
+ columns: (1fr, 1fr),
+ gutter: 1em,
+ block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(10)
+ #lazy-v(1fr)
+ Bottom left.
+ ],
+ block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(20)
+ #lazy-v(1fr)
+ Bottom right.
+ ],
+))
+```
+
+:::tip[提示]
+
+如果不需要高度对齐的行为,可以给 `cols` 传入 `lazy-layout: false` 来关闭。
+
+:::
+
+## 防止内容溢出
+
+默认情况下,当幻灯片内容超出页面高度时,Touying 会自动将多余内容溢出到下一页。这在大多数场景下是合理的,但在某些需要严格控制页面映射关系的场景(如 AI 智能体工作流)中,你可能希望禁止这种行为。
+
+使用 `config-common(breakable: false)` 可以防止内容溢出:
+
+```typst
+// Prevent overflow, panic on overflow (default behavior when breakable: false)
+#show: simple-theme.with(
+ config-common(breakable: false),
+)
+
+// Prevent overflow and visually clip overflowing content
+#show: simple-theme.with(
+ config-common(breakable: false, clip: true),
+)
+
+// Prevent overflow, disable overflow detection (performance-first)
+#show: simple-theme.with(
+ config-common(breakable: false, detect-overflow: false),
+)
+```
+
+配合使用的参数:
+
+- **`clip`**(默认 `false`):设为 `true` 时,超出幻灯片高度的内容会被视觉截断。
+- **`detect-overflow`**(默认 `true`):设为 `true` 时,会通过布局测量检测溢出,一旦内容高度超出幻灯片高度则直接 `panic()` 报错,便于及早发现问题;设为 `false` 可避免额外的布局开销。
+
+:::note[注意]
+
+`clip`、`detect-overflow` 这两个参数仅在 `breakable: false` 时生效。
+
+:::
+
+你也可以在演示文稿中途通过 `touying-set-config` 动态切换这些配置:
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme.with(config-common(breakable: false))
+== This slide's overflow will be clipped
+
+// Enable clipping for a specific slide
+#show: touying-set-config.with(config-common(clip: true))
+
+#lorem(500)
+```
+
--- /dev/null
+---
+sidebar_position: 6
+---
+
+# 多文件架构
+
+Touying 有着如同原生 Typst 文档一般简洁的语法,以及繁多的可自定义配置项,却也仍能够维持着实时的增量编译性能,因此很适合用来编写大型 slides。
+
+如果你需要写一个较大的 slides,例如一个几十页几百页的课程讲义,你也可以尝试一下 Touying 的多文件架构。
+
+
+## 配置和内容分离
+
+一个最简单的 Touying 多文件架构包括三个文件:全局配置文件 `globals.typ`、主入口文件 `main.typ` 和存放内容的 `content.typ` 文件。
+
+分成三个文件是由于要让 `main.typ` 和 `content.typ` 均可以引入 `globals.typ`,从而避免循环引用。
+
+`globals.typ` 可以用于存放一些全局的自定义函数,以及对 Touying 主题进行初始化:
+
+```typst
+// globals.typ
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+// as well as some utility functions
+```
+
+`main.typ` 作为项目的主入口,通过导入 `globals.typ` 应用 show rules,以及通过 `#include` 置入 `content.typ`。
+
+```typst
+// main.typ
+#import "/globals.typ": *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#include "content.typ"
+```
+
+`content.typ` 便是用于书写具体内容的文件了。
+
+```typst
+// content.typ
+#import "/globals.typ": *
+
+= The Section
+
+== Slide Title
+
+Hello, Touying!
+
+#focus-slide[
+ Focus on me.
+]
+```
+
+
+## 多章节
+
+要实现多章节也十分简单,只需要新建一个 `sections` 目录,并将上面的 `content.typ` 文件移动至 `sections.typ` 目录即可,例如
+
+```typst
+// main.typ
+#import "/globals.typ": *
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+
+#include "sections/content.typ"
+// #include "sections/another-section.typ"
+```
+
+和
+
+```typst
+// sections/content.typ
+#import "/globals.typ": *
+
+= The Section
+
+== Slide Title
+
+Hello, Touying!
+
+#focus-slide[
+ Focus on me.
+]
+```
+
+这样,您就掌握了如何使用 Touying 实现大型 slides 的多文件架构。
\ No newline at end of file
--- /dev/null
+{
+ "label": "进度与章节",
+ "position": 7,
+ "link": {
+ "type": "generated-index",
+ "description": "在 Touying 中管理和显示进度,包括幻灯片计数器、章节跟踪和附录。"
+ }
+}
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# 幻灯片计数器与进度
+
+Touying 提供了一组计数器和工具函数,用于追踪和显示演示文稿的播放进度。
+
+## 幻灯片计数器
+
+`utils.slide-counter` 是 Typst 主计数器,每张幻灯片时递增。
+
+```typst
+// 显示当前幻灯片编号
+#context utils.slide-counter.display()
+```
+
+在自定义页脚中使用:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ footer: context [Slide #utils.slide-counter.display()],
+ ),
+)
+
+= Section
+
+== First Slide
+
+Content here.
+
+== Second Slide
+
+More content.
+```
+
+## 幻灯片总数
+
+`utils.last-slide-number` 保存**附录之前**最后一张幻灯片的编号。这通常用作"第 X / Y 页"页脚中的分母:
+
+```typst
+#context utils.slide-counter.display() + " / " + utils.last-slide-number
+```
+
+## 进度条
+
+`utils.touying-progress` 提供一个 0.0 至 1.0 的比例值,表示当前在演示文稿中的进度:
+
+```typst
+#utils.touying-progress(ratio => {
+ // ratio 是一个介于 0.0 和 1.0 之间的浮点数
+ box(width: ratio * 100%, height: 4pt, fill: primary)
+})
+```
+
+metropolis 和 aqua 主题的进度条即以此方式实现。
+
+## 附录
+
+`appendix` show 规则会停止幻灯片计数器,使附录幻灯片不改变页脚中显示的总数:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Main Section
+
+== Introduction
+
+The slide count increments normally here.
+
+== Second Slide
+
+Still counting.
+
+#show: appendix
+
+= Appendix
+
+== Backup Slide
+
+The footer still shows the count from the last main slide.
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 2
+---
+# 章节工具函数
+Touying 会在每张幻灯片中注入不可见的标题,以便你随时可以通过 Typst 的 `query()` 函数查询当前章节信息。
+## 显示当前标题
+`utils.display-current-heading(level: N)` 返回指定级别最近一个标题的文本内容。大多数主题用它来填充页眉:
+```typst
+// 在页眉中显示当前章节(第 1 级)
+utils.display-current-heading(level: 1)
+// 显示当前小节(第 2 级)
+utils.display-current-heading(level: 2)
+```
+`utils.display-current-short-heading(level: N)` 是去除编号的简短变体:
+```typst
+utils.display-current-short-heading(level: 2)
+```
+## 在自定义页眉中显示章节名称
+你可以在自定义页眉中使用这些工具函数:
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.default: *
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-page(
+ header: [
+ #text(gray, utils.display-current-heading(level: 2))
+ #h(1fr)
+ #context utils.slide-counter.display()
+ ],
+ ),
+)
+= My Section
+== First Slide
+Header shows "First Slide" on the right side.
+== Second Slide
+Header updates automatically.
+```
+## 渐进式目录
+
+Touying 提供了若干渐进式目录工具函数,以下是最常用的几种。
+
+### 渐进式目录(默认)
+
+[`components.progressive-outline()`](https://touying-typ.github.io/docs/reference/components/progressive-outline) 渲染一个高亮当前章节、灰显其他章节的目录——这是主题演示文稿中的常见模式:
+```example
+#import "@preview/touying:0.7.0": *
+#import themes.dewdrop: *
+#show: dewdrop-theme.with(aspect-ratio: "16-9")
+= Introduction
+== Overview <touying:hidden>
+#components.progressive-outline()
+= Background
+== Slide
+Content.
+```
+[`components.adaptive-columns(outline(...))`](https://touying-typ.github.io/docs/reference/components/adaptive-columns) 是另一种变体,它将标准 [`outline()`](https://typst.app/docs/reference/model/outline/) 包裹在适当数量的列中,使其恰好占满一页。
+
+### 自定义渐进式目录
+
+[`components.custom-progressive-outline()`](https://touying-typ.github.io/docs/reference/components/custom-progressive-outline) 允许你为渐进式目录指定各种样式规则,灵活性更强,但需要自行配置所有参数。
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+#show: dewdrop-theme.with(aspect-ratio: "16-9")
+= Introduction
+== Overview <touying:hidden>
+#components.custom-progressive-outline(
+ level: 1,
+ show-past: (true, false),
+ show-future: (true, false),
+ show-current: (true, true, false),
+ vspace: (.5em, .0em),
+ numbering: ("1.1",),
+ numbered: (true,true),
+ title: none,
+)
+= Background
+== Slide
+Content.
+```
+
+注意,这里需要自行指定所有参数,没有现成的默认样式。部分参数会自动重复,而另一些则不会。如果你不喜欢这种方式,也可以直接通过 `set` 规则修改目录条目。为此,我们提供了一个辅助函数来获取当前章节的上下文信息。
+
+### 章节关系辅助函数
+
+工具函数 [`utils.section-relationship()`](https://touying-typ.github.io/docs/reference/utils/section-relationship) 用于获取当前所在章节与给定目录条目之间的关系,返回值为 (-2, -1, 0, 1, 2) 中的一个整数。
+
+负数表示文档中较早声明的标题,正数表示较晚声明的标题。只有当前标题**及其子标题**的关系值为 `0`。
+
+-1 和 1 保留给与当前章节同属同一顶级标题下的其他标题。结合 `outline.entry.level` 提供的实际层级信息,这些应足以构建任意你想要的目录样式。
+
+示例用法如下:
+```example
+>>>#import "@preview/touying:0.7.3": *
+>>>#import themes.simple: *
+>>>#show: simple-theme
+>>>#set heading(numbering: "1.1")
+
+= Start
+== Start Sub
+#lorem(5)
+= My content
+== My heading
+#lorem(5)
+---
+#{// 正常显示所有顶级标题及当前顶级标题下的所有层级,
+ // 未来的同级标题和其他顶级标题显示为半透明,
+ // 当前条目加粗,其余条目显示为红色。
+
+ show outline.entry: it => {
+ let relationship = utils.section-relationship(it)
+ let current = utils.current-heading()
+ let alpha = if relationship == -2 or relationship > 0 { 40% } else { 100% }
+ let weight = if relationship == 0 and current.level == it.level {
+ "bold"
+ } else { "regular" }
+ if it.level > 1 and calc.abs(relationship) > 1 {
+ text(fill: red, it) //通常这里填 `none`。
+ } else {
+ text(fill: utils.update-alpha(text.fill, alpha), weight: weight, it)
+ }
+ }
+ outline(title: none)
+}
+---
+=== Subsubheading
+#lorem(3)
+
+== Another heading
+#lorem(5)
+
+= Next Top Level
+
+== Subsection
+#lorem(5)
+```
\ No newline at end of file
--- /dev/null
+---
+sidebar_position: 2
+---
+
+# 节与小节
+
+## 结构
+
+与 Beamer 相同,Touying 同样有着 section 和 subsection 的概念。
+
+一般而言,1 级、2 级和 3 级标题分别用来对应 section、subsection 和 subsubsection,例如 dewdrop 主题。
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.dewdrop: *
+
+#show: dewdrop-theme.with(aspect-ratio: "16-9")
+
+= Section
+
+== Subsection
+
+=== Title
+
+Hello, Touying!
+```
+
+但是很多时候我们并不需要 subsection,因此也会使用 1 级和 2 级标题来分别对应 section 和 title,例如 university 主题。
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.university: *
+
+#show: university-theme.with(aspect-ratio: "16-9")
+
+= Section
+
+== Title
+
+Hello, Touying!
+```
+
+实际上,我们可以通过 `config-common` 函数的 `slide-level` 参数来控制这里的行为。`slide-level` 代表着嵌套结构的复杂度,从 0 开始计算。例如 `#show: university-theme.with(config-common(slide-level: 2))` 等价于 `section` 和 `subsection` 都会创建新 slide;而 `#show: university-theme.with(config-common(slide-level: 3))` 等价于 `section`,`subsection` 和 `subsubsection` 都会创建新 slide。
+
+
+## 编号
+
+为了给节与小节加入编号,我们只需要使用
+
+```typst
+#set heading(numbering: "1.1")
+#show heading.where(level: 1): set heading(numbering: "1.")
+```
+
+即可设置默认编号为 `1.1`,且 section 对应的编号为 `1.`。
+
+
+## 目录
+
+在 Touying 中显示目录很简单:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#import "@preview/numbly:0.1.0": numbly
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Section
+
+== Subsection
+
+#components.adaptive-columns(outline(indent: 1em))
+```
+
+其中 `outline(indent: 1em)` 是 Typst 的原生目录函数。而 `#components.adaptive-columns()` 函数可以让目录尽可能只占据一个页面,即它会自适应分别设置 `#columns(1, body)` 或者 `#columns(2, body)`,以此类推。
+
+如果你需要一个可以显示当前进度的 `outline` 函数,你可以考虑使用 `#components.progressive-outline()` 或 `#components.custom-progressive-outline()`,就像 dewdrop 主题那样。
+或者通过操控 `outline.entry` 元素自行编写,对于某些特定效果,你可能需要用到 `#utils.section-relationship`。
+## 特殊标题标签
+
+Touying 识别标题上的特殊标签以控制幻灯片行为:
+
+| 标签 | 效果 |
+|------|------|
+| `<touying:hidden>` | 幻灯片完全不渲染(内容和页面均被抑制)。 |
+| `<touying:skip>` | 该标题不创建新的章节幻灯片。 |
+| `<touying:unnumbered>` | 幻灯片不计入幻灯片计数器。 |
+| `<touying:unoutlined>` | 该标题从 `outline()` 中排除。 |
+| `<touying:unbookmarked>` | 不为该标题生成 PDF 书签。 |
+| `<touying:handout>` | 该幻灯片仅在讲义模式下显示。 |
+
+示例——使用 `<touying:hidden>` 标签隐藏目录幻灯片,使其不出现在最终 PDF 中:
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+#import "@preview/numbly:0.1.0": numbly
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= First Section
+
+== Slide One
+
+Content.
+```
+
+## 附录
+
+`appendix` 函数会停止幻灯片计数器,使附录幻灯片不影响页脚中显示的总数。
+
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Main Section
+
+== Introduction
+
+Main content here. Check the slide number in the footer.
+
+#show: appendix
+
+= Appendix
+
+== Appendix Slide
+
+The slide number is frozen at the last main-section slide.
+```
--- /dev/null
+---
+sidebar_position: 3
+---
+
+# 全局设置
+
+## 全局样式
+
+对 Touying 而言,全局样式即为需要应用到所有地方的 set rules 或 show rules,例如 `#set text(size: 20pt)`。
+
+其中,Touying 的主题会封装一些自己的全局样式,他们会被放在 `#self.methods.init` 中,例如 simple 主题就封装了:
+```typst
+config-methods(
+ init: (self: none, body) => {
+ set text(fill: self.colors.neutral-darkest, size: 25pt)
+ show footnote.entry: set text(size: .6em)
+ show strong: self.methods.alert.with(self: self)
+ show heading.where(level: self.slide-level + 1): set text(1.4em)
+
+ body
+ },
+)
+```
+
+如果你并非一个主题制作者,而只是想给你的 slides 添加一些自己的全局样式,你可以简单地将它们放在 `#show: xxx-theme.with()` 之前或之后。例如 metropolis 主题就推荐你自行加入以下全局样式:
+```typst
+#set text(font: "Fira Sans", weight: "light", size: 20pt)
+#show math.equation: set text(font: "Fira Math")
+#set strong(delta: 100)
+#set par(justify: true)
+```
+
+## 全局信息
+
+就像 Beamer 一样,Touying 通过统一 API 设计,能够帮助您更好地维护全局信息,让您可以方便地在不同的主题之间切换,全局信息就是一个很典型的例子。
+
+你可以通过
+```typc
+config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: [logo.png],
+ extra: (supervisor:[Supervisor],),
+)
+```
+
+你甚至可以传入额外信息,以维护其他属性未涵盖的演示文稿信息。
+
+在后续,你就可以通过 `self.info` 这样的方式访问它们。
+
+这些信息一般会在主题的 `title-slide`、`header` 和 `footer` 被使用到,例如 `#show: metropolis-theme.with(aspect-ratio: "16-9", footer: self => self.info.institution)`。
+
+其中 `date` 可以接收 `datetime` 格式和 `content` 格式,并且 `datetime` 格式的日期显示格式,可以通过
+```typc
+config-common(datetime-format: "[year]-[month]-[day]")
+```
+
+的方式更改。
+
+## 前言(Preamble)
+
+`config-common(preamble: ...)` 选项允许你在每张幻灯片上执行初始化代码,而无需手动重复。这在集成 `codly` 等包时非常有用:
+```typst
+#show: simple-theme.with(
+ config-common(preamble: {
+ codly(languages: codly-languages)
+ }),
+)
+```
+
+你也可以针对单张幻灯片局部设置此选项,详见下文。
+
+## 全局配置覆盖(Show-Rule)
+
+你可以使用 `#show: touying-set-config.with(...)` 覆盖当前及其后所有幻灯片的任意配置,用法与普通的 `show`/`set` 规则相同:
+```example
+#import "@preview/touying:0.7.3": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+== Normal Slide
+
+This slide uses the default settings.
+
+
+
+== Blue Background Slide
+#show: touying-set-config.with(config-page(fill: blue.lighten(80%)))
+This slide has a blue background applied via `touying-set-config`.
+
+== Red Accent Slide
+#show: touying-set-config.with(config-colors(primary: red))
+This slide uses a red primary color, e.g. in `#alert` boxes.
+
+#alert[This is an alert box with red accent color.]
+
+== Changed Cover
+#show: touying-set-config.with(config-methods(
+ cover: utils.semi-transparent-cover,
+))
+Initial Content.
+
+#pause
+
+Content that appears with a semi-transparent cover effect.
+```
+
+## 局部配置覆盖
+
+如果你只想影响某一张幻灯片,可以通过 `#slide(config: ...)[...]` 局部设置配置:
+```example
+>>> #import "../lib.typ": *
+>>> #import themes.simple: *
+
+>>> #show: simple-theme.with(aspect-ratio: "16-9")
+== Local Config
+#slide(config:config-page(fill: purple.lighten(90%)))[
+Only this slide has a light purple background, but the next slide goes back being light blue.
+]
+```
+
+## 延迟配置(Deferred Config Show Rules)
+
+你也可以将配置变更推迟到下一张幻灯片开始时生效。`show: appendix` 正是通过此机制实现的,同样适用于需要在幻灯片内容之外生效的自定义前言等场景。(注意 `config-common` 在此处无效,你也可以不使用它直接书写配置。)
+```example
+>>> #import "../lib.typ": *
+>>> #import themes.simple: *
+
+>>> #show: simple-theme.with(aspect-ratio: "16-9")
+== Content Slide
+Some content.
+#show: touying-set-config.with(defer:true, config-common(appendix:true))
+// you can just write `show: appendix`
+== Appendix
+Page counter does no longer increase.
+#show: touying-set-config.with(defer:true, (preamble:{codly(languages: codly-languages)}))
+== Deferred Config Change
+Now we have codly available.
+```
+
+## 冻结计数器
+
+在使用动画时,单张幻灯片内的图表和定理计数器默认会随每个子幻灯片递增。若要冻结某个计数器(使其在子幻灯片之间保持不变),请使用:
+```typst
+config-common(frozen-counters: (figure.where(kind: image),))
+```
+
+在使用 [Theorion](../integration/theorion.md) 包时这尤为有用:
+```typst
+config-common(frozen-counters: (theorem-counter,))
+```
+
+## 访问配置信息
+
+你可以使用 `touying-get-config` 访问幻灯片的已存储配置。该配置为全局配置与该幻灯片所有覆盖设置的综合结果。
+
+请注意,它在 `context` 时机求值,并在你请求的位置插入到文档流中,因此只能以内容(content)形式使用。
+
+### 查询完整配置
+
+不传参数调用 `touying-get-config()` 可以获取完整的配置字典,然后通过普通的字典语法访问嵌套值:
+
+```typst
+#touying-get-config().info.author
+
+#touying-get-config().common.handout
+```
+
+由于 `common` 下的字段是注册在了顶层,你可以直接访问:
+
+```typst
+#touying-get-config().handout // 等同于 .common.handout
+```
+
+### 通过 key 查询
+
+传入以点号分隔的字符串 key,可以直接获取特定的子配置或值:
+
+```typst
+#touying-get-config("info.author")
+
+#touying-get-config("info") // 返回整个 info 子字典
+```
+
+### 默认值
+
+如果 key 不存在,`touying-get-config` 默认会 panic。如果你希望提供一个回退值,可以使用 `default` 参数:
+
+```typst
+#touying-get-config("random.dict.value", default: "default value")
+```
+
+### 访问自定义配置
+
+通过 `touying-set-config` 设置的自定义 key,在 `show` 规则之后即可访问:
+
+```typst
+#show: touying-set-config.with((random: (dict: (value: 123))))
+
+#touying-get-config("random.dict.value") // 显示 "123"
+```
+
+:::warning[警告]
+
+访问自定义配置时,必须使用字符串 key 形式(`touying-get-config("random.dict.value")`),而不是链式字典访问(`touying-get-config("random.dict").value`),因为后者会尝试在 content 元素上访问 `.value`,这会导致失败。
+
+:::
\ No newline at end of file
--- /dev/null
+{
+ "label": "工具函数",
+ "position": 8,
+ "link": {
+ "type": "generated-index",
+ "description": "Touying 提供的便捷工具函数。"
+ }
+}
--- /dev/null
+---
+sidebar_position: 1
+---
+
+# Fit to height / width
+
+感谢 [ntjess](https://github.com/ntjess) 的代码。
+
+## Fit to height
+
+如果你需要将图片占满剩余的 slide 高度,你可以来试试 `fit-to-height` 函数:
+
+```typst
+#utils.fit-to-height(1fr)[BIG]
+```
+
+函数定义:
+
+```typst
+#let fit-to-height(
+ width: none, prescale-width: none, grow: true, shrink: true, height, body
+) = { .. }
+```
+
+参数:
+
+- `width`: 如果指定,这将确定缩放后内容的宽度。因此,如果您希望缩放的内容填充幻灯片宽度的一半,则可以使用 `width: 50%`。
+- `prescale-width`: 此参数允许您使 Typst 的布局假设给定的内容在缩放之前要布局在一定宽度的容器中。例如,您可以使用 `prescale-width: 200%` 假设幻灯片的宽度为原来的两倍。
+- `grow`: 是否可扩张,默认为 `true`。
+- `shrink`: 是否可收缩,默认为 `true`。
+- `height`: 需要指定的高度。
+- `body`: 具体的内容。
+
+
+## Fit to width
+
+如果你需要限制标题宽度刚好占满 slide 的宽度,你可以来试试 `fit-to-width` 函数:
+
+```typst
+#utils.fit-to-width(1fr)[#lorem(20)]
+```
+
+函数定义:
+
+```typst
+#let fit-to-width(grow: true, shrink: true, width, body) = { .. }
+```
+
+参数:
+
+- `grow`: 是否可扩张,默认为 `true`。
+- `shrink`: 是否可收缩,默认为 `true`。
+- `width`: 需要指定的宽度。
+- `body`: 具体的内容。
+
+
+## 实用示例:将表格适配到幻灯片
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ #utils.fit-to-height(1fr)[
+ #table(
+ columns: (1fr, 1fr, 1fr),
+ [A], [B], [C],
+ [1], [2], [3],
+ [4], [5], [6],
+ )
+ ]
+]
+```
+
+## 将标题适配到全宽
+
+```example
+>>> #import "@preview/touying:0.7.3": *
+>>> #import themes.simple: *
+>>> #show: simple-theme
+#slide[
+ #utils.fit-to-width(1fr)[
+ #text(weight: "bold")[A Very Long Presentation Title That Should Fill the Entire Slide Width]
+ ]
+]
+```
--- /dev/null
+#import "/lib.typ": *
+#import themes.aqua: *
+
+#show: aqua-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [标题],
+ subtitle: [副标题],
+ author: [作者],
+ date: datetime.today(),
+ institution: [机构],
+ ),
+)
+
+#set text(lang: "zh")
+
+#title-slide()
+
+#outline-slide()
+
+= 第一节
+
+== 小标题
+
+#slide[
+ #lorem(40)
+]
+
+#slide[
+ #lorem(40)
+]
+
+== 总结
+
+#slide(self => [
+ #align(center + horizon)[
+ #set text(size: 3em, weight: "bold", self.colors.primary)
+
+ THANKS FOR ALL
+
+ 敬请指正!
+ ]
+])
--- /dev/null
+#import "/lib.typ": *
+#import themes.aqua: *
+
+#show: aqua-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#title-slide()
+
+#outline-slide()
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+
+#focus-slide[
+ Another variant with primary color in background...
+]
+
+== Summary
+
+#slide(self => [
+ #align(center + horizon)[
+ #set text(size: 3em, weight: "bold", fill: self.colors.primary)
+ THANKS FOR ALL
+ ]
+])
+
+
--- /dev/null
+#import "/lib.typ": *
+#import themes.default: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: default-theme.with(
+ aspect-ratio: "16-9",
+ config-common(
+ slide-level: 3,
+ zero-margin-header: false,
+ ),
+ config-colors(primary: blue),
+ config-methods(alert: utils.alert-with-primary-color),
+ config-page(
+ header: text(gray, utils.display-current-short-heading(level: 2)),
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+= Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= Title
+
+== Recall <recall>
+
+*Recall*
+
+#speaker-note[Recall]
+
+#show: touying-set-config.with(config-methods(
+ cover: utils.semi-transparent-cover,
+))
+
+== Animation
+
+#set math.equation(numbering: "(1)")
+
+Simple
+
+#pause
+
+$ x + y $
+
+animation
+
+#touying-recall(<recall>)
+
+
+#show: appendix
+
+= Appendix
+
+Appendix
--- /dev/null
+#import "/lib.typ": *
+#import themes.dewdrop: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ navigation: "mini-slides",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+$ x_(n+1) = (x_n + a / x_n) / 2 $
+
+== Subsection A.2
+
+A slide without a title but with *important* infos
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+#import "/lib.typ": *
+#import themes.university: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: edge, node
+#import "@preview/numbly:0.1.0": numbly
+#import "@preview/theorion:0.6.0": *
+#import cosmos.clouds: *
+#show: show-theorion
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ // align: horizon,
+ // config-common(handout: true),
+ config-common(frozen-counters: (theorem-counter,)), // freeze theorem counter for animation
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= Animation
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+
+== Complex Animation
+
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+
+
+== Callback Style Animation
+
+#slide(
+ repeat: 3,
+ self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+ ],
+)
+
+
+== Math Equation Animation
+
+Equation with `pause`:
+
+$
+ f(x) & = pause x^2 + 2x + 1 \
+ & = pause (x + 1)^2 \
+$
+
+#meanwhile
+
+Here, #pause we have the expression of $f(x)$.
+
+#pause
+
+By factorizing, we can obtain this result.
+
+
+== CeTZ Animation
+
+CeTZ Animation in Touying:
+
+#cetz-canvas({
+ import cetz.draw: *
+
+ rect((0, 0), (5, 5))
+
+ (pause,)
+
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+
+ (pause,)
+
+ line((0, 0), (2.5, 2.5), name: "line")
+})
+
+
+== Fletcher Animation
+
+Fletcher Animation in Touying:
+
+#fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(
+ blue.lighten(80%),
+ blue,
+ center: (30%, 20%),
+ radius: 80%,
+ ),
+ spacing: 4em,
+ edge((-1, 0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0, 0), `reading`, radius: 2em),
+ edge((0, 0), (0, 0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1, 0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2, 0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0, 0), (2, 0), `close()`, "-|>", bend: -40deg),
+)
+
+
+= Theorems
+
+== Prime numbers
+
+#definition[
+ A natural number is called a #highlight[_prime number_] if it is greater
+ than 1 and cannot be written as the product of two smaller natural numbers.
+]
+#example[
+ The numbers $2$, $3$, and $17$ are prime.
+ @cor_largest_prime shows that this list is not exhaustive!
+]
+
+#theorem(title: "Euclid")[
+ There are infinitely many primes.
+]
+#pagebreak(weak: true)
+#proof[
+ Suppose to the contrary that $p_1, p_2, dots, p_n$ is a finite enumeration
+ of all primes. Set $P = p_1 p_2 dots p_n$. Since $P + 1$ is not in our list,
+ it cannot be prime. Thus, some prime factor $p_j$ divides $P + 1$. Since
+ $p_j$ also divides $P$, it must divide the difference $(P + 1) - P = 1$, a
+ contradiction.
+]
+
+#corollary[
+ There is no largest prime number.
+] <cor_largest_prime>
+#corollary[
+ There are infinitely many composite numbers.
+]
+
+#theorem[
+ There are arbitrarily long stretches of composite numbers.
+]
+
+#proof[
+ For any $n > 2$, consider $ n! + 2, quad n! + 3, quad ..., quad n! + n $
+]
+
+
+= Others
+
+== Multiple columns
+
+#cols[
+ First column.
+][
+ Second column.
+]
+
+== Multiple columns with equal height blocks
+
+#cols(columns: (1fr, 1fr), gutter: 1em)[
+ #emph-block[
+ First column with equal height: #lorem(10)
+ #lazy-v(1fr)
+ ]
+][
+ #emph-block[
+ Second column with equal height: : #lorem(15)
+ #lazy-v(1fr)
+ ]
+]
+
+
+== Multiple Pages
+
+#lorem(200)
+
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+#import "/lib.typ": *
+#import themes.metropolis: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.city,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide(indent: (1em,), depth: 1)
+
+= First Section
+
+A slide without a title but with some *important* information.
+
+== A long long long long long long long long long long long long long long long long long long long long long long long long Title
+
+A slide with equation:
+
+$ x_(n+1) = (x_n + a / x_n) / 2 $
+
+#lorem(200)
+
+= Second Section
+
+#focus-slide[
+ Wake up!
+]
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+#show: appendix
+
+= Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ footer: [Simple slides],
+)
+
+#title-slide[
+ = Keep it simple!
+ #v(2em)
+
+ Alpha #footnote[Uni Augsburg] #h(1em)
+ Bravo #footnote[Uni Bayreuth] #h(1em)
+ Charlie #footnote[Uni Chemnitz] #h(1em)
+
+ July 23
+]
+
+== First slide
+
+#lorem(20)
+
+#focus-slide[
+ _Focus!_
+
+ This is very important.
+]
+
+= Let's start a new section!
+
+== Dynamic slide
+
+Did you know that...
+
+#pause
+
+...you can see the current section at the top of the slide?
--- /dev/null
+#import "/lib.typ": *
+#import themes.stargazer: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: stargazer-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Stargazer in Touying: Customize Your Slide Title Here],
+ subtitle: [Customize Your Slide Subtitle Here],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+#tblock(title: [Theorem])[
+ A simple theorem.
+
+ $ x_(n+1) = (x_n + a / x_n) / 2 $
+]
+
+== Subsection A.2
+
+A slide without a title but with *important* information.
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+#import "/lib.typ": *
+#import themes.university: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ contact: [contact\@mail.com],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide(authors: ([Author A], [Author B]))
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+
+#focus-slide[
+ Another variant with primary color in background...
+]
+
+#matrix-slide[
+ left
+][
+ middle
+][
+ right
+]
+
+#matrix-slide(columns: 1)[
+ top
+][
+ bottom
+]
+
+#matrix-slide(columns: (1fr, 2fr, 1fr), ..(lorem(8),) * 9)
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: edge, node
+
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ footer: [Waypoints — Touying],
+)
+
+#let code-col(code-text) = {
+ set text(size: 16pt)
+ block(
+ width: 100%,
+ fill: luma(245),
+ inset: 6pt,
+ radius: 4pt,
+ raw(block: true, lang: "typst", code-text),
+ )
+}
+#set text(size: 20pt)
+
+#title-slide[
+ = Waypoints
+ _Name your subslides, not count them._
+ #v(1em)
+ A Touying Waypoints Guide
+]
+
+= The Problem
+
+== Why waypoints?
+
+With numeric indices, inserting a `#pause` shifts every number:
+
+#grid(
+ columns: (1fr, 1fr),
+ column-gutter: 1em,
+ [
+ *Before* — correct:
+ ```typst
+ A #pause B #pause C
+ #uncover("2-")[From 2]
+ #only(3)[Only on 3]
+ ```
+ ],
+ [
+ *After* adding a pause — broken:
+ ```typst
+ A #pause A2 #pause B #pause C
+ #uncover("2-")[From 2] // wrong!
+ #only(3)[Only on 3] // wrong!
+ ```
+ ],
+)
+
+#pause
+
+*Waypoints* replace fragile numbers with stable names:
+
+```typst
+#waypoint(<reveal>) // named pause
+#uncover(<reveal>)[content] // refers to the name
+```
+
+And in reality we don't need to know every single animation step, special ones are often enough.
+Note: Waypoints work in contexts like fletcher, but not inside text blocks like raw.
+
+
+= Explicit Waypoints
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`#waypoint` + `#uncover`]
+ #code-col(
+ "Content before.\n#waypoint(<demo>)\nFirst Content.\n#pause\nSecond Content.\n#uncover(<demo>)[\n Revealed during waypoint.\n]",
+ )
+ `#waypoint` acts like `#pause` but names the position.
+ `#uncover(<lbl>)` shows content during that waypoint's range.
+][
+ Content before.
+ #waypoint(<demo>)
+ First Content.
+ #pause
+ Second Content.
+ #uncover(<demo>)[Revealed during waypoint.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`#waypoint` + `#uncover` (multiple)]
+ #code-col(
+ "
+ #waypoint(<intro>)
+ Intro phase.
+ #pause
+ More Intro.
+ #waypoint(<detail>)
+ Detail phase.
+ #uncover(<intro>)[During intro.]
+ #uncover(<detail>)[During detail.]
+ ",
+ )
+ Each waypoint owns a range of subslides. Content uncovered with a label is visible only during that range.
+][
+ #waypoint(<intro>)
+ Intro phase.
+ #pause
+ More Intro.
+ #waypoint(<detail>)
+ Detail phase.
+ #uncover(<intro>)[During _intro_.]
+ #uncover(<detail>)[During _detail_.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`#waypoint` + `#effect`]
+ #code-col(
+ "Normal text.\n#waypoint(<hl>)\n#effect(\n text.with(fill: red), <hl>\n)[Red during <hl>.]",
+ )
+ `#effect(fn, <lbl>)` applies a transform while the waypoint is active.
+][
+ Normal text.
+ #waypoint(<hl>)
+ #effect(text.with(fill: red), <hl>)[Red during `<hl>`.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[No-advance waypoint]
+ #code-col(
+ "#waypoint(<here>, advance: false)\nStill subslide 1.\n#uncover(<here>)[\n Visible immediately.\n]",
+ )
+ `advance: false` marks the position *without* creating a new subslide.
+][
+ #waypoint(<here>, advance: false)
+ Still subslide 1.
+ #uncover(<here>)[Visible immediately.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Explicit Start Index]
+ #code-col(
+ "#waypoint(<end>, start: 3)\nEnds at subslide 3.\n#v(4em)\n#waypoint(<start>, start:1)\nWe start down here.\n#pause\nAnd will continue further up.",
+ )
+ `start: int` starts the waypoint at a specific subslide index. `start: <lbl>` starts it at another waypoint's position.
+][
+ #waypoint(<end>, start: 3)
+ Ends at subslide 3.
+
+ #v(4em)
+
+ #waypoint(<start>, start: 1)
+ We start down here.\
+ #pause
+ And will continue further up.
+]
+
+= Querying Waypoints
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`get-first` / `get-last`]
+ #code-col(
+ "#waypoint(<p>)\nStart. #pause\nContinued.\n#waypoint(<q>)\nNext.\n#only(get-first(<p>))[First of p.]\n#only(get-last(<p>))[Last of p.]",
+ )
+ A waypoint spanning multiple subslides (due to `#pause` inside its range) can be queried at its edges.
+][
+ #waypoint(<p>)
+ Start. #pause
+ Continued.
+ #waypoint(<q>)
+ Next.
+ #only(get-first(<p>))[First of `<p>` only.]
+ #only(get-last(<p>))[Last of `<p>` only.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`from-wp` — onward from a waypoint]
+ #code-col(
+ "#waypoint(<step>)\nStep content.\n#waypoint(<later>)\nLater content.\ \n#uncover(from-wp(<step>))[\n From step onward — through `<later>` too.\n]",
+ )
+ #text(
+ size: 20pt,
+ )[`from-wp(<lbl>)` is visible from the waypoint's first subslide to the *end of the slide*. Unlike a bare label, it is not bounded by the next waypoint.]
+][
+ #waypoint(<step>)
+ Step content.
+ #waypoint(<later>)
+ Later content. \
+ #uncover(from-wp(<step>))[From `<step>` onward — through `<later>` too.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`until-wp` — before a waypoint]
+ #code-col(
+ "#waypoint(<phase-1>)\nPhase 1 content.\n#waypoint(<phase-2>)\nPhase 2 content.\n#uncover(until-wp(<phase-2>))[\n Before phase 2.\n]\n#uncover(from-wp(<phase-2>))[\n Only from phase 2.\n]",
+ )
+ `until-wp(<lbl>)` is visible on all subslides *before* the waypoint starts — including subslides before any waypoint is reached.
+][
+ #waypoint(<phase-1>)
+ Phase 1 content.
+ #waypoint(<phase-2>)
+ Phase 2 content.
+ #uncover(until-wp(<phase-2>))[Before phase 2.]
+ #uncover(from-wp(<phase-2>))[Only from phase 2.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Inverted waypoints]
+ #code-col(
+ "Base Content
+#waypoint(<middle>)
+Middle - 1
+#pause
+Middle - 2
+#waypoint(<end>)
+End Content
+
+#alternatives(at: (not-wp(<middle>), <middle>)\n)[Not during middle.][During middle.]",
+ )
+ Similar to "!" for string ranges, we can use `<not-wp>` to invert the selection based on a waypoint.
+][
+ Base Content\
+ #waypoint(<middle>)
+ Middle - 1\
+ #pause
+ Middle - 2\
+ #waypoint(<end>)
+ End Content\
+
+ #alternatives(at: (
+ not-wp(<middle>),
+ <middle>,
+ ))[Not during middle.][During middle.]
+]
+
+= Ranges & Navigation
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Bounded range]
+ #code-col(
+ "#waypoint(<ra>)\nRange A.\n#waypoint(<rb>)\nRange B.\n#waypoint(<rc>)\nRange C.\n#uncover(\n (from-wp(<ra>), until-wp(<rc>))\n)[During A and B.]",
+ )
+ Combine `from-wp` and `until-wp` in an array to span a range. Runs from `<ra>` up to (but not including) `<rc>`.
+][
+ #waypoint(<ra>)
+ Range A.
+ #waypoint(<rb>)
+ Range B.
+ #waypoint(<rc>)
+ Range C.
+ #uncover((from-wp(<ra>), until-wp(<rc>)))[During A and B.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`prev-wp` / `next-wp`]
+ #v(-0.5em)
+ #code-col(
+ "#waypoint(<na>)\nSection A.\n#waypoint(<nb>)\nSection B.\n#waypoint(<nc>)\nSection C.\n#only(next-wp(<na>))[\n During B (next after A).\n]\n#only(prev-wp(<nc>))[\n During B (prev before C).\n]",
+ )
+ Jump to the adjacent waypoint in subslide order.
+ In fact you may also pass an `amount` to jump multiple waypoints: `next-wp(<lbl>, amount: 2)`.
+][
+ #waypoint(<na>)
+ Section A.
+ #waypoint(<nb>)
+ Section B.
+ #waypoint(<nc>)
+ Section C.
+ #only(next-wp(<na>))[During B (next after A).]
+ #only(prev-wp(<nc>))[During B (prev before C).]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Composing shifts with `from-wp`/`until-wp`]
+ #code-col(
+ "#waypoint(<ca>)\nPart A.\n#waypoint(<cb>)\nPart B.\n#waypoint(<cc>)\nPart C.\n// from-wp(next-wp(<ca>)) = from B\n#uncover(from-wp(next-wp(<ca>)))[\n From B onward.\n]\n// until-wp(prev-wp(<cc>)) = until B\n#uncover(until-wp(prev-wp(<cc>)))[\n Only during A.\n]",
+ )
+ Shifts compose naturally with `from-wp`/`until-wp`.
+][
+ #waypoint(<ca>)
+ Part A.
+ #waypoint(<cb>)
+ Part B.
+ #waypoint(<cc>)
+ Part C.
+ #uncover(from-wp(next-wp(<ca>)))[From B onward.]
+ #uncover(until-wp(prev-wp(<cc>)))[Only during A.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Inclusive range via `next-wp`]
+ #code-col(
+ "#waypoint(<ia>)\nPart A.\n#waypoint(<ib>)\nPart B.\n#waypoint(<ic>)\nPart C.\n// A only (exclusive of B)\n#only((from-wp(<ia>), until-wp(<ib>)))[\n Exactly A.\n]\n// A and B (inclusive)\n#only((from-wp(<ia>),\n next-wp(until-wp(<ib>)))\n)[A and B.]",
+ )
+ `next-wp(until-wp(<ib>))` → `until-wp(<ic>)`, so `<ib>` is included.
+][
+ #waypoint(<ia>)
+ Part A.
+ #waypoint(<ib>)
+ Part B.
+ #waypoint(<ic>)
+ Part C.
+ #only((from-wp(<ia>), until-wp(<ib>)))[Exactly during A.]
+ #only((from-wp(<ia>), next-wp(until-wp(<ib>))))[During A and B (inclusive).]
+]
+
+= Implicit Waypoints
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Labels as auto-waypoints]
+ #code-col(
+ "Always visible.\n#uncover(<imp-rev>)[\n Appears via implicit wp.\n]",
+ )
+ Using a label in `#uncover`, `#only`, or `#effect` creates a waypoint automatically — no `#waypoint` call needed.
+][
+ Always visible.
+ #uncover(<imp-rev>)[Appears via implicit waypoint.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Multiple implicit]
+ #code-col(
+ "Base content.\n#uncover(<im-a>)[Phase A.]\n#effect(\n text.with(fill: blue), <im-b>\n)[Phase B styled.]\n#only(<im-c>)[Phase C only.]",
+ )
+ Each distinct label → one implicit waypoint. Reusing a label adds no extra subslides.
+][
+ Base content.
+ #uncover(<im-a>)[Phase A.]
+ #effect(text.with(fill: blue), <im-b>)[Phase B styled.]
+ #only(<im-c>)[Phase C only.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Mixed explicit + implicit]
+ #code-col(
+ "#waypoint(<expl>)\nExplicit phase.\n#uncover(<impl>)[\n Implicit phase.\n]\n#uncover(from-wp(<expl>))[\n From explicit onward.\n]",
+ )
+][
+ #waypoint(<expl>)
+ Explicit phase.
+ #uncover(<impl>)[Implicit phase.]
+ #uncover(from-wp(<expl>))[From explicit onward.]
+]
+
+= Forward References
+
+== Using waypoints before they are defined
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[Forward reference]
+ #code-col(
+ "// Reference before definition\n#uncover(until-wp(<summary>))[\n Shown before summary.\n]\nContent.\n#waypoint(<summary>)\nSummary text.\n#uncover(from-wp(<summary>))[\n Summary visible.\n]",
+ )
+ Waypoints can be referenced *before* they are defined on the same slide. `from-wp`/`until-wp` are lazy markers resolved at render time — after all waypoints are collected.
+][
+ #uncover(until-wp(<summary>))[Shown before summary.]
+ Content.
+ #waypoint(<summary>)
+ Summary text.
+ #uncover(from-wp(<summary>))[Summary visible.]
+]
+
+== Rules for waypoint references
+
+- *Forward references work.* `from-wp(<lbl>)`, `until-wp(<lbl>)`, and bare `<lbl>` can appear before `#waypoint(<lbl>)` (or other waypoint creating functions) on the same slide.
+
+- *Cross-slide references do not work.* Waypoints are scoped to a single slide. A label defined on slide 3 cannot be used on slide 5.
+
+- *Undefined waypoints are errors.* If a label is referenced but never defined on the slide (by `#waypoint` or an implicit label), Touying raises an error at the end of the slide.
+
+- *Implicit waypoints are created at first use.* `#uncover(<lbl>)` both references and defines the waypoint — a forward reference is never needed for implicit waypoints.
+
+= Advanced Usage
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`#alternatives` with `at:`]
+ #code-col(
+ "#waypoint(<opt-a>)\nOption A active.\n#waypoint(<opt-b>)\nOption B active.\n#alternatives(\n at: (<opt-a>, <opt-b>)\n)[Content A.][Content B.]",
+ )
+ Maps each body to a named waypoint instead of sequential numbering.
+][
+ #waypoint(<opt-a>)
+ Option A active.
+ #waypoint(<opt-b>)
+ Option B active.
+ #alternatives(at: (<opt-a>, <opt-b>))[Content *A*.][Content *B*.]
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`item-by-item` — relative (auto)]
+ #code-col(
+ "Text before.\n#pause\nAfter first pause.\n#item-by-item[\n - First item\n - Second item\n - Third item\n]\n#pause\nAfter second pause.",
+ )
+ With `start: auto` (default), items continue from the current pause position.
+
+ They always stay for the remainder of the slide.
+][
+ Text before.
+ #pause
+ After first pause.
+ #item-by-item[
+ - First item
+ - Second item
+ - Third item
+ ]
+ #pause
+ After second pause.
+]
+
+#slide(composer: (1fr, 1fr))[
+ #text(weight: "bold")[`item-by-item` with waypoint start]
+ #code-col(
+ "#waypoint(<list-wp>)\n#item-by-item(start: <list-wp>)[\n - Alpha\n - Beta\n - Gamma\n]\n#uncover(from-wp(<list-wp>))[\n List revealed above.\n]\n#only(<final>)[\n Finally after the list.\n]",
+ )
+ Anchor the item reveal to a named waypoint (implicit also possible).
+][
+ #waypoint(<list-wp>)
+ #item-by-item(start: <list-wp>)[
+ - Alpha
+ - Beta
+ - Gamma
+ ]
+ #uncover(from-wp(<list-wp>))[List revealed above.]
+
+ // #waypoint(<final>, advance:false)
+ #only(<final>)[Finally after the list.]
+]
+
+== Callback style
+
+#slide(self => {
+ block(width: 50%)[
+ #code-col(
+ "#slide(self => {
+ let (uncover, only) = utils.methods(self)
+ [
+ Base content.
+ #waypoint(<cb-a>)
+ #uncover(<cb-a>)[Uncovered from cb-a.]
+ #waypoint(<cb-b>)
+ #only(<cb-b>)[Only during cb-b.]
+ ]
+})",
+ )
+ ]
+ [
+ #let (uncover, only) = utils.methods(self)
+ Base content.
+ #waypoint(<cb-a>)
+ #uncover(<cb-a>)[Uncovered from `<cb-a>`.]
+ #waypoint(<cb-b>)
+ #only(<cb-b>)[Only during `<cb-b>`.]
+ ]
+})
+
+== Hierarchical Waypoints
+
+#slide(composer: (1fr, 1fr))[
+ #code-col(
+ "#alternatives(at: (<intro>, <more>))[Introduction.][More details.]
+ #waypoint(<intro:background>)
+ Some background during intro.
+ #waypoint(<intro:goal>)
+ The goal during intro.
+ #waypoint(<more:analysis>)
+ Analysis during more.
+ #waypoint(<more:results>)
+ Results during more.",
+ )
+ You may even construct hierarchical waypoints, which we collect automatically by their shared top level. \ We use ':' as the separator of levels.
+][
+ #alternatives(at: (<intro>, <more>))[*Introduction.*][*More details.*]\
+ #waypoint(<intro:background>)
+ Some background during intro.
+ #waypoint(<intro:goal>)
+ The goal during intro. \
+ #waypoint(<more:analysis>)
+ Analysis during more.
+ #waypoint(<more:results>)
+ Results during more.
+]
+
+== Navigating Hierarchies with `next-wp` / `prev-wp`
+
+#slide(composer: (1fr, 1fr))[
+ #v(-1em)
+ #code-col(
+ "#waypoint(<before>, advance: false)
+Before the group.
+#waypoint(<grp:a>)
+Part A.
+#waypoint(<grp:b>)
+Part B.
+#waypoint(<after>)
+After the group.
+// Virtual parent (no <grp> waypoint):
+// next-wp → last child + 1
+#only(next-wp(<grp>))[Past the group (after).]
+// prev-wp → first child − 1
+#only(prev-wp(<grp>))[Before the group (prev)]
+",
+ )
+ #v(-0.8em)
+ When `<grp>` is a _virtual_ parent (only children like `<grp:a>` etc. exist), `next-wp` anchors to the *last* child and `prev-wp` to the *first* — so they navigate _past_ or _before_ the entire group.
+
+][
+
+ #waypoint(<hn-before>, advance: false)
+ Before the group.
+ #waypoint(<hn-grp:a>)
+ Part A.
+ #waypoint(<hn-grp:b>)
+ Part B.
+ #waypoint(<hn-after>)
+ After the group.
+
+ #only(next-wp(<hn-grp>))[Past the group (after).]
+ #only(prev-wp(<hn-grp>))[Before the group (prev)]
+
+ #uncover("0-")[#place(
+ bottom,
+ )[You can also reach children: `next-wp(get-first(<grp>))` starts at the first child and steps forward within the group. \
+ If an explicit `#waypoint(<grp>)` exists, it anchors to that label directly — stepping into the children from there.]]
+]
+
+= Integration: CeTZ & Fletcher
+
+== Waypoints inside `touying-reducer`
+
+Waypoints work inside `touying-reducer`, letting you name animation steps in CeTZ and Fletcher diagrams.
+
+First, set up the reducer bindings (once, at the top of your file):
+#show block: set text(size: 16pt)
+#grid(
+ columns: (1fr, 1fr),
+ fill: luma(245),
+ inset: 5pt,
+ column-gutter: 1em,
+ [
+ *CeTZ:* #v(-0.5em)
+ ```typst
+ #import "@preview/cetz:0.5.0"
+ #let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+ )
+ ```
+ ],
+ [
+ *Fletcher:* #v(-0.5em)
+ ```typst
+ #import "@preview/fletcher:0.5.8" as fletcher: edge, node
+ #let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+ )
+ ```
+ ],
+)
+Then use `(waypoint(<lbl>),)` for CeTZ or `waypoint(<lbl>)` for Fletcher inside the diagram — just like `(pause,)` or `pause`.
+
+
+== Fletcher: First Isomorphism Theorem
+
+#slide(composer: (1.5fr, 1fr))[
+ #show block: set text(size: 12pt)
+ #v(-1em)
+ #code-col(
+ "#fletcher-diagram(
+ cell-size: 15mm,
+ waypoint(<fl-maps>, advance: false),
+ node((0, 0), $G$),
+ edge((0, 0), (1, 0), $f$, \"->\"),
+ edge((0, 0), (0, 1), $pi$, \"->>\"),
+ pause,
+ node((1, 0), $im(f)$),
+ node((0, 1), $G\\/ker(f)$),
+ waypoint(<fl-iso>),
+ edge((0, 1), (1, 0), $tilde(f)$, \"hook-->\"),
+)
+#alternatives(
+ at: (get-first(<fl-maps>), from-wp(get-last(<fl-maps>)))
+)[
+ $f: G -> $, $pi: G ->> $ ][
+ $f: G -> im(f)$, $pi: G ->> G\/ker(f)$ ]
+#uncover(<fl-iso>)[$tilde(f)$: the isomorphism.]
+",
+ )
+ #v(-0.5em)
+ Use math as labels, not for the whole diagram when using `pause` or `waypoint` inside it. We cannot recognize it otherwise.
+][
+ #fletcher-diagram(
+ cell-size: 15mm,
+ waypoint(<fl-maps>, advance: false),
+ node((0, 0), $G$),
+ edge((0, 0), (1, 0), $f$, "->"),
+ edge((0, 0), (0, 1), $pi$, "->>"),
+ pause,
+ node((1, 0), $im(f)$),
+ node((0, 1), $G\/ker(f)$),
+ waypoint(<fl-iso>),
+ edge((0, 1), (1, 0), $tilde(f)$, "hook-->"),
+ ) \ \
+ #alternatives(
+ at: (get-first(<fl-maps>), from-wp(get-last(<fl-maps>))),
+ )[
+ $f: G ->$,\ $pi: G ->>$
+ ][
+ $f: G -> im(f)$, \ $pi: G ->> G\/ker(f)$
+ ] \
+ #uncover(<fl-iso>)[$tilde(f)$: the isomorphism.]
+]
+
+== CeTZ: 3D Sine Waves
+
+#slide(composer: (1.5fr, 1fr))[
+ #show block: set text(size: 12pt)
+ #code-col(
+ "#cetz-canvas({
+ import cetz.draw: *
+ let wave(amp, col) = { ... }
+ (waypoint(<cz-grid>, advance: false),)
+ ortho(y: -30deg, x: 30deg, {
+ on-xz({grid((0,-2), (8,2), stroke: gray + .5pt)})
+ })
+ (waypoint(<cz-xy>),)
+ ortho(y: -30deg, x: 30deg, {on-xy({ wave(1.6, blue) })})
+ (waypoint(<cz-xz>),)
+ ortho(y: -30deg, x: 30deg, {on-xz({ wave(1.0, red) })})
+})
+#only(<cz-grid>)[Grid plane.]
+#only(<cz-xy>)[Blue wave (xy).]
+#only(<cz-xz>)[Red wave (xz).]",
+ )
+ #set text(size: 16pt)
+ Similarly, for CeTZ, `pause` and `waypoint` must be at the top level of the canvas. Split `ortho(...)` and similar functions into separate calls — one per animation step. Do not put `pause` or `waypoint` inside functions like `ortho` or `on-xz`.
+][
+ #cetz-canvas({
+ import cetz.draw: *
+ let N = 50
+ let wave(amp, fill-col, stroke-col) = {
+ line(
+ ..(
+ for i in range(N + 1) {
+ let t = i / N
+ let p = 4 * calc.pi * t
+ ((t * 8, calc.sin(p) * amp),)
+ }
+ ),
+ stroke: stroke-col + 1.2pt,
+ fill: fill-col,
+ )
+ for phase in range(0, 2) {
+ let x0 = phase / 2
+ for div in range(1, 9) {
+ let p = 2 * calc.pi * (div / 8)
+ let y = calc.sin(p) * amp
+ let x = x0 * 8 + div / 8 * 4
+ line((x, 0), (x, y), stroke: stroke-col.transparentize(40%) + .5pt)
+ }
+ }
+ }
+ (waypoint(<cz-grid>, advance: false),)
+ ortho(y: -30deg, x: 30deg, {
+ on-xz({
+ grid(
+ (0, -2),
+ (8, 2),
+ stroke: gray + .5pt,
+ )
+ })
+ })
+ (waypoint(<cz-xy>),)
+ ortho(y: -30deg, x: 30deg, {
+ on-xy({
+ wave(1.6, rgb(0, 0, 255, 50), blue)
+ })
+ })
+ (waypoint(<cz-xz>),)
+ ortho(y: -30deg, x: 30deg, {
+ on-xz({
+ wave(1.0, rgb(255, 0, 0, 50), red)
+ })
+ })
+ })
+
+ #v(0.5em)
+ #only(<cz-grid>)[Grid plane drawn.]
+ #only(<cz-xy>)[Blue wave on the xy-plane.]
+ #only(<cz-xz>)[Red wave on the xz-plane.]
+]
+
+= Summary
+
+== Quick reference
+
+#set text(size: 13pt)
+
+#table(
+ columns: (auto, 1fr),
+ stroke: 0.5pt,
+ inset: 5pt,
+ align: (left, left),
+ table.header[*Syntax*][*Effect*],
+ [`#waypoint(<lbl>)`], [Named `#pause` — marks + advances],
+ [`#waypoint(<lbl>, advance: false)`], [Marks without advancing],
+ [`#waypoint(<lbl>, start:int|<lbl>)`],
+ [Starts the waypoint at a specific subslide],
+
+ [`#uncover(<lbl>)[...]`], [Show during waypoint range (implicit)],
+ [`#only(<lbl>)[...]`], [Show only during range (implicit)],
+ [`#effect(fn, <lbl>)[...]`], [Apply style during range (implicit)],
+ [`get-first(<lbl>)`], [First subslide of the range],
+ [`get-last(<lbl>)`], [Last subslide of the range],
+ [`from-wp(<lbl>)`], [From waypoint to end of slide],
+ [`until-wp(<lbl>)`], [Before waypoint (exclusive)],
+ [`next-wp(<lbl>, amount:1)`],
+ [Adjacent waypoint (forward), allows `amount` to skip multiple],
+
+ [`prev-wp(<lbl>, amount:1)`],
+ [Adjacent waypoint (backward), allows `amount` to skip multiple],
+
+ [`(from-wp(<a>), until-wp(<b>))`], [Bounded range: `<a>` to before `<b>`],
+ [`not-wp(<lbl>)`], [Inverted range: not during `<lbl>`],
+ [`#alternatives(at: (..,))[..][..]`], [Named alternative mapping],
+ [`#item-by-item[...]`], [Relative item reveal (auto from pause)],
+ [`#item-by-item(start: <wp>)[...]`], [Waypoint-anchored item reveal],
+ [`<label:sublabel>`],
+ [Hierarchical waypoint. The parent (e.g. `<label>`) refers to all its children (e.g. `<label:sublabel>`).],
+
+ [`(waypoint(<lbl>),)` inside CeTZ],
+ [Waypoint inside `touying-reducer` (CeTZ). Wrap in tuple like `(pause,)`.],
+
+ [`waypoint(<lbl>)` inside Fletcher],
+ [Waypoint inside `touying-reducer` (Fletcher). No tuple needed.],
+)
--- /dev/null
+/// // #image("https://github.com/user-attachments/assets/58a91b14-ae1a-49e2-a3e7-5e3a148e2ba5")
+///
+/// #link("https://github.com/touying-typ/touying")[Touying] (投影 in chinese, /tóuyǐng/, meaning projection) is a user-friendly, powerful and efficient package for creating presentation slides in Typst. Partial code is inherited from #link("https://github.com/andreasKroepelin/polylux")[Polylux]. Therefore, some concepts and APIs remain consistent with Polylux.
+///
+/// Touying provides automatically injected global configurations, which is convenient for configuring themes. Besides, Touying does not rely on `counter` and `context` to implement `#pause`, resulting in better performance.
+///
+/// If you like it, consider #link("https://github.com/touying-typ/touying")[giving a star on GitHub]. Touying is a community-driven project, feel free to suggest any ideas and contribute.
+///
+/// == Example
+///
+/// Split slides by headings #link("https://touying-typ.github.io/docs/tutorials/sections")[document]
+///
+/// #example(````
+/// #import "@preview/touying:0.7.3": *
+/// #import themes.simple: *
+///
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+///
+/// = Section
+///
+/// == Subsection
+///
+/// === Title
+///
+/// Hello, Touying!
+/// ````)
+///
+/// == Example
+///
+/// `#pause` and `#meanwhile` animations #link("https://touying-typ.github.io/docs/tutorials/dynamic/simple")[document]
+///
+/// #example(```
+/// #import "@preview/touying:0.7.3": *
+/// #import themes.simple: *
+///
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+///
+/// = Section
+///
+/// == Subsection
+///
+/// First, we pause here.
+///
+/// #pause
+///
+/// Then, we continue with the next part.
+/// ```)
+
+#import "src/exports.typ": *
+#import "themes/themes.typ"
--- /dev/null
+#import "utils.typ"
+#import "extern.typ": warning
+
+#let cell = block.with(
+ width: 100%,
+ height: 100%,
+ above: 0pt,
+ below: 0pt,
+ outset: 0pt,
+ breakable: false,
+)
+
+
+/// Lazy fractional vertical space, used with `lazy-layout` to push content to the
+/// bottom of a block while keeping sibling blocks at equal height without filling the
+/// entire page.
+///
+/// Has no visual effect without `lazy-layout`. If a column contains multiple `lazy-v`
+/// markers (stacked blocks), only the last one is activated.
+///
+/// Example:
+/// ```typ
+/// #lazy-layout(grid(
+/// columns: (1fr, 1fr),
+/// block(width: 100%)[
+/// #lorem(10)
+/// #lazy-v(1fr)
+/// Bottom left.
+/// ],
+/// block(width: 100%)[
+/// #lorem(20)
+/// #lazy-v(1fr)
+/// Bottom right.
+/// ],
+/// ))
+/// ```
+///
+/// - amount (fraction): The fractional amount of space (e.g. `1fr`).
+///
+/// - weak (bool): Whether the space is weak. Default is `false`.
+///
+/// -> content
+#let lazy-v(amount, weak: false) = {
+ assert(
+ type(amount) == fraction,
+ message: "lazy-v: `amount` must be a fraction (e.g. 1fr), got "
+ + repr(amount),
+ )
+ [#parbreak()#metadata((
+ amount: amount,
+ weak: weak,
+ ))<touying-lazy-v>#parbreak()]
+}
+
+/// Lazy fractional horizontal space, the horizontal counterpart of `lazy-v`.
+/// Used with `lazy-layout(direction: ltr)` to push content to the right edge of a
+/// block while keeping sibling blocks at equal width without filling the entire page.
+///
+/// Has no visual effect without a matching `lazy-layout`. If a row contains multiple
+/// `lazy-h` markers (stacked blocks), only the last one is activated.
+///
+/// Example:
+/// ```typ
+/// #lazy-layout(
+/// direction: ltr,
+/// stack(
+/// dir: ltr,
+/// block(height: 100%)[
+/// Left label. #lazy-h(1fr) Right label.
+/// ],
+/// block(height: 100%)[
+/// A longer left label. #lazy-h(1fr) Right label.
+/// ],
+/// ),
+/// )
+/// ```
+///
+/// - amount (fraction): The fractional amount of space (e.g. `1fr`).
+///
+/// - weak (bool): Whether the space is weak. Default is `false`.
+///
+/// -> content
+#let lazy-h(amount, weak: false) = {
+ assert(
+ type(amount) == fraction,
+ message: "lazy-h: `amount` must be a fraction (e.g. 1fr), got "
+ + repr(amount),
+ )
+ [#metadata((
+ amount: amount,
+ weak: weak,
+ ))<touying-lazy-h>]
+}
+
+/// Make multiple blocks match the size of the tallest (or widest) sibling without
+/// expanding to fill the entire page.
+///
+/// - `direction: ttb` (default): equalizes block *heights* via `lazy-v`.
+/// - `direction: ltr`: equalizes block *widths* via `lazy-h`.
+///
+/// If a column (or row) contains multiple lazy markers (stacked blocks), only the last
+/// one is activated.
+///
+/// Use `cols(lazy-layout: true)` as a convenient shorthand for the vertical case.
+///
+/// ```typ
+/// #lazy-layout(grid(
+/// columns: (1fr, 1fr),
+/// block(width: 100%)[
+/// #lorem(10)
+/// #lazy-v(1fr)
+/// Bottom left.
+/// ],
+/// block(width: 100%)[
+/// #lorem(20)
+/// #lazy-v(1fr)
+/// Bottom right.
+/// ],
+/// ))
+/// ```
+///
+/// - direction (direction): The equalization axis (`ttb`/`btt` for heights, `ltr`/`rtl` for widths). Default is `ttb`.
+///
+/// - body (content): The content containing `lazy-v` or `lazy-h` markers.
+///
+/// -> content
+#let lazy-layout(direction: ttb, body) = {
+ [#metadata((:))<lazy-layout-begin>]
+ layout(container-size => context {
+ // Query lazy marker positions within this lazy-layout scope.
+ // When lazy-layout is used inside a `measure` environment (e.g. from
+ // `page-container`'s detect-overflow), the metadata labels are not part of
+ // the real document flow, so `query` returns an empty array. In that case
+ // we gracefully fall back to rendering the body without lazy processing.
+ let begin-candidates = query(selector(<lazy-layout-begin>).before(here()))
+ let end-candidates = query(selector(<lazy-layout-end>).after(here()))
+ if begin-candidates.len() == 0 or end-candidates.len() == 0 {
+ // Fallback: render body as-is without lazy spacing adjustments.
+ body
+ } else {
+ let begin-loc = begin-candidates.last().location()
+ let end-loc = end-candidates.first().location()
+
+ let is-vertical = direction.axis() == "vertical"
+ if is-vertical {
+ // Collect positions of all lazy-v markers in this scope.
+ let lazy-v-items = query(
+ selector(<touying-lazy-v>).after(begin-loc).before(end-loc),
+ )
+ let lazy-v-positions = lazy-v-items.map(it => it.location().position())
+ // For each x coordinate, find the last marker's position (the one to activate).
+ // Group by x and keep only the last position per group.
+ let last-positions = {
+ let result = (:)
+ for pos in lazy-v-positions {
+ let key = repr(pos.x)
+ result.insert(key, pos)
+ }
+ result.values()
+ }
+
+ // Phase 1: measure height with all lazy-v markers hidden.
+ let measured-size = measure(block(
+ width: container-size.width,
+ body,
+ ))
+ // Phase 2: render at the measured height.
+ // Only the last lazy-v marker per x coordinate is activated; others stay hidden.
+ show <touying-lazy-h>: it => panic(
+ "lazy-layout: found a lazy-h marker inside a vertical lazy-layout. "
+ + "Use lazy-v markers for vertical layouts, or pass direction: ltr to lazy-layout.",
+ )
+ show <touying-lazy-v>: it => {
+ let pos = it.location().position()
+ if last-positions.any(lp => lp.x == pos.x and lp.y == pos.y) {
+ v(it.value.amount, weak: it.value.weak)
+ }
+ }
+ block(height: measured-size.height, body)
+ } else {
+ // Collect positions of all lazy-h markers in this scope.
+ let lazy-h-items = query(
+ selector(<touying-lazy-h>).after(begin-loc).before(end-loc),
+ )
+ let lazy-h-positions = lazy-h-items.map(it => it.location().position())
+ // For each y coordinate, find the last marker's position (the one to activate).
+ let last-positions = {
+ let result = (:)
+ for pos in lazy-h-positions {
+ let key = repr(pos.y)
+ result.insert(key, pos)
+ }
+ result.values()
+ }
+
+ // Phase 1: measure width with all lazy-h markers hidden.
+ let measured-size = measure(block(
+ height: container-size.height,
+ body,
+ ))
+ // Phase 2: render at the measured width.
+ // Only the last lazy-h marker per y coordinate is activated; others stay hidden.
+ show <touying-lazy-v>: it => panic(
+ "lazy-layout: found a lazy-v marker inside a horizontal lazy-layout. "
+ + "Use lazy-h markers for horizontal layouts, or pass direction: ttb to lazy-layout.",
+ )
+ show <touying-lazy-h>: it => {
+ let pos = it.location().position()
+ if last-positions.any(lp => lp.y == pos.y and lp.x == pos.x) {
+ h(it.value.amount, weak: it.value.weak)
+ }
+ }
+ block(width: measured-size.width, body)
+ }
+ }
+ })
+ [#metadata((:))<lazy-layout-end>]
+}
+
+// Alias used inside `cols` to avoid the `lazy-layout` parameter shadowing the function.
+#let _lazy-layout = lazy-layout
+
+/// A simple wrapper around `grid` that creates a single-row grid. Used as the default `composer` for multi-body slides.
+///
+/// Example: `cols[a][b][c]` will display `a`, `b`, and `c` as columns side by side.
+///
+/// - columns (auto, array): The column widths. Default is `auto`, which creates equal-width columns matching the number of bodies.
+///
+/// - gutter (length): The space between columns. Default is `1em`.
+///
+/// - lazy-layout (bool): When `true`, wraps the grid with `lazy-layout` so that
+/// `lazy-v` markers inside the bodies are resolved correctly. Default is `true`.
+///
+/// - bodies (content): The contents to display side by side as columns side by side.
+///
+/// -> content
+#let cols(columns: auto, gutter: 1em, lazy-layout: true, ..bodies) = {
+ let args = bodies.named()
+ let bodies = bodies.pos()
+ if bodies.len() == 1 {
+ return if lazy-layout {
+ _lazy-layout(bodies.first())
+ } else {
+ bodies.first()
+ }
+ }
+ let columns = if columns == auto {
+ (1fr,) * bodies.len()
+ } else {
+ columns
+ }
+ let result = grid(columns: columns, gutter: gutter, ..args, ..bodies)
+ if lazy-layout {
+ _lazy-layout(result)
+ } else {
+ result
+ }
+}
+
+
+/// A simple wrapper around `grid` that creates a single-row grid. Used as the default `composer` for multi-body slides. Alias for `cols`.
+///
+/// Example: `side-by-side(gutter: 1em)[a][b][c]` will display `a`, `b`, and `c` side by side.
+///
+/// - columns (auto, array): The column widths. Default is `auto`, which creates equal-width columns matching the number of bodies.
+///
+/// - gutter (length): The space between columns. Default is `1em`.
+///
+/// - lazy-layout (bool): When `true`, wraps the grid with `lazy-layout` so that
+/// `lazy-v` markers inside the bodies are resolved correctly. Default is `true`.
+///
+/// - bodies (content): The contents to display side by side.
+///
+/// -> content
+#let side-by-side = cols
+
+
+/// Adaptive columns layout that automatically chooses the number of columns based on content height.
+///
+/// Example: `components.adaptive-columns(outline())`
+///
+/// - gutter (length): The space between columns. Default is `4%`.
+///
+/// - max-count (int): The maximum number of columns. Default is `3`.
+///
+/// - start (content, none): The content to place before the columns. Default is `none`.
+///
+/// - end (content, none): The content to place after the columns. Default is `none`.
+///
+/// - body (content): The content to place in the columns.
+///
+/// -> content
+#let adaptive-columns(
+ gutter: 4%,
+ max-count: 3,
+ start: none,
+ end: none,
+ body,
+) = layout(size => {
+ let n = calc.min(
+ calc.ceil(
+ measure(body).height
+ / (size.height - measure(start).height - measure(end).height),
+ ),
+ max-count,
+ )
+ if n < 1 {
+ n = 1
+ }
+ start
+ if n == 1 {
+ body
+ } else {
+ columns(n, body)
+ }
+ end
+})
+
+
+/// Touying progress bar.
+///
+/// - primary (color): The color of the progress bar.
+///
+/// - secondary (color): The color of the background of the progress bar.
+///
+/// - height (length): The height of the progress bar, optional. Default is `2pt`.
+///
+/// -> content
+#let progress-bar(height: 2pt, primary, secondary) = utils.touying-progress(
+ ratio => {
+ grid(
+ columns: (ratio * 100%, 1fr),
+ rows: height,
+ gutter: 0pt,
+ cell(fill: primary), cell(fill: secondary),
+ )
+ },
+)
+
+
+/// Place two content blocks at the left and right edges of the available width using a three-column grid.
+///
+/// - left (content): The content of the left part.
+///
+/// - right (content): The content of the right part.
+///
+/// -> content
+#let left-and-right(left, right) = grid(
+ columns: (auto, 1fr, auto),
+ left, none, right,
+)
+
+
+/// Create a slide where the provided content blocks are displayed in a grid with a checkerboard color pattern.
+///
+/// You can configure the grid using the `rows` and `columns` keyword arguments (both default to `none`):
+///
+/// - If `columns` is an integer, create that many columns of width `1fr`.
+/// - If `columns` is `none`, create as many columns of width `1fr` as there are content blocks.
+/// - Otherwise assume that `columns` is an array of widths already, use that.
+/// - If `rows` is an integer, create that many rows of height `1fr`.
+/// - If `rows` is `none`, create as many rows of height `1fr` as needed given the number of content blocks and columns.
+/// - Otherwise assume that `rows` is an array of heights already, use that.
+///
+/// That means that `#checkerboard[...][...]` stacks horizontally and `#checkerboard(columns: 1)[...][...]` stacks vertically.
+///
+/// - columns (int, array, none): The column specification. Default is `none`.
+///
+/// - rows (int, array, none): The row specification. Default is `none`.
+///
+/// - alignment (alignment): The alignment applied to the contents of each checkerboard cell. Default is `center + horizon`.
+///
+/// - primary (color): The background color of odd cells. Default is `white`.
+///
+/// - secondary (color): The background color of even cells. Default is `silver`.
+///
+/// -> content
+#let checkerboard(
+ columns: none,
+ rows: none,
+ alignment: center + horizon,
+ primary: white,
+ secondary: silver,
+ ..bodies,
+) = {
+ let bodies = bodies.pos()
+ let columns = if type(columns) == int {
+ (1fr,) * columns
+ } else if columns == none {
+ (1fr,) * bodies.len()
+ } else {
+ columns
+ }
+ let num-cols = columns.len()
+ let rows = if type(rows) == int {
+ (1fr,) * rows
+ } else if rows == none {
+ let quotient = calc.quo(bodies.len(), num-cols)
+ let correction = if calc.rem(bodies.len(), num-cols) == 0 {
+ 0
+ } else {
+ 1
+ }
+ (1fr,) * (quotient + correction)
+ } else {
+ rows
+ }
+ let num-rows = rows.len()
+ if num-rows * num-cols < bodies.len() {
+ panic(
+ "number of rows ("
+ + str(num-rows)
+ + ") * number of columns ("
+ + str(num-cols)
+ + ") must at least be number of content arguments ("
+ + str(
+ bodies.len(),
+ )
+ + ")",
+ )
+ }
+ let cart-idx(i) = (calc.quo(i, num-cols), calc.rem(i, num-cols))
+ let color-body(idx-body) = {
+ let (idx, body) = idx-body
+ let (row, col) = cart-idx(idx)
+ let color = if calc.even(row + col) {
+ primary
+ } else {
+ secondary
+ }
+ set align(alignment)
+ rect(inset: .5em, width: 100%, height: 100%, fill: color, body)
+ }
+ let body = grid(
+ columns: columns, rows: rows,
+ gutter: 0pt,
+ ..bodies.enumerate().map(color-body)
+ )
+ body
+}
+
+
+/// Show progressive outline. It will make other sections except the current section to be semi-transparent.
+///
+/// - alpha (ratio): The transparency of the other sections. Default is `60%`.
+///
+/// - level (int): The level of the outline. Default is `1`.
+///
+/// - transform (function): A function applied to each outline entry. It receives `(cover: bool, level: int, alpha: ratio, ..args, it)` where `cover` is `true` when the entry should be visually de-emphasized, `it` is the outline entry element, and `alpha` is the transparency value.
+///
+/// - args (arguments): Additional arguments forwarded to the inner `outline()` call, see https://typst.app/docs/reference/model/outline/.
+///
+/// -> content
+#let progressive-outline(
+ alpha: 60%,
+ level: 1,
+ transform: (cover: false, alpha: 60%, ..args, it) => if cover {
+ text(utils.update-alpha(text.fill, alpha), it)
+ } else {
+ it
+ },
+ ..args,
+) = (
+ context {
+ // start page and end page
+ let start-page = 1
+ let end-page = calc.inf
+ if level != none {
+ let current-heading = utils.current-heading(level: level)
+ if current-heading != none {
+ start-page = current-heading.location().page()
+ if level != auto {
+ let next-headings = query(
+ selector(heading.where(level: level)).after(
+ inclusive: false,
+ current-heading.location(),
+ ),
+ )
+ if next-headings != () {
+ end-page = next-headings.at(0).location().page()
+ }
+ } else {
+ end-page = start-page + 1
+ }
+ }
+ }
+ show outline.entry: it => transform(
+ cover: it.element.location().page() < start-page
+ or it.element.location().page() >= end-page,
+ level: level,
+ alpha: alpha,
+ ..args,
+ it,
+ )
+
+ outline(..args)
+ }
+)
+
+
+/// A fully-featured progressive outline that renders headings from multiple levels with per-level styling.
+///
+/// Uses arrays indexed by heading level (first element = level 1, second = level 2, etc.) to apply different styling to each level. Unlike `progressive-outline` (a thin wrapper around Typst's built-in `outline`), this function renders each heading manually, giving full control over numbering, indentation, fills, and typography.
+/// For styling parameters the last value in the array is used for all levels beyond the array length, it is repeated. So you can write `indent: (1em,)` to apply a `1em` indentation to all levels, or `indent: (0em, 1em)` to apply no indentation to level-1 headings and `1em` to level-2 and beyond. This is not the case for `numbering` or `vspace` nor for `filled`, `numbered`, `paged`.
+///
+/// - self (none): The self context.
+///
+/// - alpha (ratio): The transparency of the covered headings. Default is `60%`.
+///
+/// - level (auto, int): The outline level. When `auto`, all levels up to `slide-level` are shown. Default is `auto`.
+///
+/// - numbered (array): Per-level booleans indicating whether headings are numbered. Default is `(false,)`. *Last value in the array is not-repeated!*
+///
+/// - filled (array): Per-level booleans indicating whether to show a fill between the heading and the page number. Default is `(false,)`. *Last value in the array is not-repeated!*
+///
+/// - paged (array): Per-level booleans indicating whether to show the page number. Default is `(false,)`. *Last value in the array is not-repeated!*
+///
+/// - numbering (array): Per-level numbering strings or `none` overrides. Default is `()`. *Last value in the array is not-repeated!*
+///
+/// - text-style (array, none): Per-level text style dicts. Default is `none` (inherits current text style). See the parameters of `text` (https://typst.app/docs/reference/text/text/).
+///
+/// - vspace (array, none): Per-level vertical space above each heading. Default is `none`. *Last value in the array is not-repeated!*
+///
+/// - title (str, none): The title of the outline section. Default is `none`.
+///
+/// - indent (array): Per-level left indentation. Default is `(0em,)`.
+///
+/// - fill (array): Per-level fill content between heading and page number. Default is `(repeat[.],)`.
+///
+/// - short-heading (bool): Whether to shorten headings that have labels using `utils.short-heading`. Default is `true`.
+///
+/// - show-past (array, function, none): Per-level booleans indicating whether to show headings for past sections. Default is `none`, reverts to the cover behaviour of `progressive-outline`. The last value in the array is used for all levels beyond the array length. \ If a function is provided instead, the function is used to style the outline entries and the styles passed to custom-progressive-outline are ignored. It receives `(level: int, it)` where `it` is the outline entry element and `level` is the heading level of that entry.
+///
+/// - show-current (array, function, none): Per-level booleans. Defaul is `none`. For more info see `show-past`.
+///
+/// - show-future (array, function, none): Per-level booleans. Default is `none`. For more info see `show-past`.
+///
+/// - style-current (array): Per level text style dicts which override text styles for the non-covered headings. Default is `none`, which uses the styles from `text-style`. See `text-style` for more details.
+///
+/// - args (arguments): Additional arguments forwarded to the underlying `outline` call, see https://typst.app/docs/reference/model/outline/.
+///
+/// -> content
+#let custom-progressive-outline(
+ self: none,
+ alpha: 60%,
+ level: auto,
+ numbered: (false,), //only applies when headings have numbering in the document
+ filled: (false,),
+ paged: (false,),
+ numbering: (), // only when numbered is true, overrides the document numbering for the outline
+ text-style: none,
+ vspace: none, // set to (0pt, ...) to linebreak the entries
+ title: none, //if set the outline will create its own top level heading
+ indent: (0em,),
+ fill: (repeat[.],),
+ short-heading: true,
+ show-past: none,
+ show-current: none,
+ show-future: none,
+ style-current: none,
+ ..args,
+) = {
+ // panic when args has uncover-fn
+ if "uncover-fn" in args.named().keys() {
+ panic(
+ "uncover-fn is no longer supported in custom-progressive-outline, use style-current instead.",
+ )
+ }
+ let named-args = args.named()
+ //for backwards compatibility, we extract text-fill, text-size and text-weight from the args and pass it into text-style
+ let merge-dep-styles(base, override, name) = {
+ let result = if base != none { base } else { () }
+ if override.len() > result.len() {
+ result = result + (range(override.len() - result.len()).map(i => (:))) //Extend result with empty dicts
+ }
+ for i in range(override.len()) {
+ if override.at(i) != none {
+ result.at(i).insert(name, override.at(i))
+ }
+ }
+ result
+ }
+ let dep-text-fill = named-args.remove("text-fill", default: ())
+ let dep-text-size = named-args.remove("text-size", default: ())
+ let dep-text-weight = named-args.remove("text-weight", default: ())
+ if (
+ not dep-text-fill == ()
+ or not dep-text-size == ()
+ or not dep-text-weight == ()
+ ) {
+ warning(
+ "Passing text-fill, text-size or text-weight to custom-progressive-outline will be deprecated in some future version. Use text-style instead.",
+ )
+ }
+ text-style = merge-dep-styles(text-style, dep-text-fill, "fill")
+ text-style = merge-dep-styles(text-style, dep-text-size, "size")
+ text-style = merge-dep-styles(text-style, dep-text-weight, "weight")
+
+ // now the actualy function
+ if level == auto {
+ level = if self != none { self.at("slide-level", default: 1) } else { 1 }
+ }
+
+ let array-at(arr, idx, d: none) = arr.at(idx, default: if arr.len() > 0 {
+ arr.last()
+ } else { d }) //with last as default
+
+ let set-text(cover, level, alpha, body) = {
+ let style-at-lvl = if not cover and type(style-current) == array {
+ array-at(style-current, level - 1, d: (:))
+ } else if type(text-style) == array {
+ array-at(text-style, level - 1, d: (:))
+ } else {
+ (:)
+ }
+ if cover {
+ style-at-lvl.insert("fill", utils.update-alpha(
+ style-at-lvl.at("fill", default: text.fill),
+ alpha,
+ ))
+ }
+ set text(..style-at-lvl)
+ body
+ }
+
+ let position(level) = {
+ let start-page = 1
+ let end-page = calc.inf
+ if level != none {
+ let current-heading = utils.current-heading(level: level)
+ if current-heading != none {
+ start-page = current-heading.location().page()
+ let headings-up-to(level) = {
+ if level <= 1 {
+ return heading.where(level: level)
+ } else {
+ return heading.where(level: level).or(headings-up-to(level - 1))
+ }
+ }
+ let next-headings = query(
+ selector(headings-up-to(level)).after(
+ inclusive: false,
+ current-heading.location(),
+ ),
+ ).at(0, default: none)
+ end-page = if next-headings != none {
+ next-headings.location().page()
+ } else {
+ calc.inf
+ }
+ }
+ }
+ return (start-page, end-page)
+ }
+
+ let transform(cover: false, alpha: alpha, it) = {
+ if type(vspace) == array and vspace.len() > it.level - 1 {
+ v(vspace.at(it.level - 1))
+ }
+
+ h(
+ range(1, it.level + 1)
+ .map(level => array-at(indent, level - 1, d: 0%))
+ .sum(),
+ )
+ set-text(
+ cover,
+ it.level,
+ alpha,
+ {
+ if array-at(numbered, it.level - 1, d: false) {
+ let current-numbering = numbering.at(
+ it.level - 1,
+ default: it.element.numbering,
+ )
+ if current-numbering != none {
+ std.numbering(
+ current-numbering,
+ ..counter(heading).at(it.element.location()),
+ )
+ h(.3em)
+ }
+ }
+ link(
+ it.element.location(),
+ {
+ if short-heading {
+ utils.short-heading(self: self, it.element)
+ } else {
+ it.element.body
+ }
+ box(
+ width: 1fr,
+ inset: (x: .2em),
+ if array-at(filled, it.level - 1, d: false) {
+ array-at(fill, it.level - 1, d: repeat[.])
+ },
+ )
+ if array-at(paged, it.level - 1, d: false) {
+ std.numbering(
+ if page.numbering != none {
+ page.numbering
+ } else {
+ "1"
+ },
+ ..counter(page).at(it.element.location()),
+ )
+ }
+ },
+ )
+ },
+ )
+ }
+
+ context {
+ let doc-pos = position(level)
+ show outline.entry: it => {
+ let cur-pos = it.element.location().page()
+
+ if cur-pos < doc-pos.first() {
+ if type(show-past) == function {
+ return show-past(it.level, it)
+ } else if (
+ type(show-past) == array
+ and not array-at(show-past, it.level - 1, d: false)
+ ) {
+ return none
+ } else {
+ //if show or show-past is none
+ transform(cover: true, alpha: alpha, it)
+ }
+ } else if cur-pos >= doc-pos.last() {
+ if type(show-future) == function {
+ return show-future(it.level, it)
+ } else if (
+ type(show-future) == array
+ and not array-at(show-future, it.level - 1, d: false)
+ ) {
+ return none
+ } else {
+ //if show or show-future is none
+ return transform(cover: true, alpha: alpha, it)
+ }
+ } else {
+ if type(show-current) == function {
+ return show-current(it.level, it)
+ } else if (
+ type(show-current) == array
+ and not array-at(show-current, it.level - 1, d: false)
+ ) {
+ return none
+ } else {
+ //if show or show-current is none
+ return transform(cover: false, alpha: alpha, it)
+ }
+ }
+ }
+ outline(title: title, ..named-args, ..args.pos())
+ }
+}
+
+/// Section navigation component showing all sections and their per-slide progress as small filled/empty circle dots.
+///
+/// Typically placed in a theme's page header. Each section is labeled with a link, and each slide within the section is represented by a small dot (filled for the current slide, hollow for others). The active section uses the full `fill` color; inactive sections have `alpha` transparency applied.
+///
+/// - self (none): The self context, used to resolve short headings.
+///
+/// - fill (color): The text and dot color. Default is `rgb("000000")`.
+///
+/// - alpha (ratio): The transparency applied to inactive sections. Default is `60%`.
+///
+/// - display-section (bool): Whether to show per-slide dots for level-1 section headings. Default is `false`.
+///
+/// - display-subsection (bool): Whether to show per-slide dots for level-2 subsection headings. Default is `true`.
+///
+/// - linebreaks (bool): Whether to insert a line break after section/subsection labels. Default is `true`.
+///
+/// - short-heading (bool): Whether to shorten heading labels using `utils.short-heading`. Default is `true`.
+///
+/// - inline (bool): Whether to place dots on the same line as the section label instead of below it. Default is `false`.
+///
+/// -> content
+#let mini-slides(
+ self: none,
+ fill: rgb("000000"),
+ alpha: 60%,
+ display-section: false,
+ display-subsection: true,
+ linebreaks: true,
+ short-heading: true,
+ inline: false,
+) = (
+ context {
+ let headings = query(
+ heading.where(level: 1).or(heading.where(level: 2)),
+ ).filter(it => it.outlined)
+ let sections = headings.filter(it => it.level == 1)
+ if sections == () {
+ return
+ }
+ let first-page = sections.at(0).location().page()
+ headings = headings.filter(it => it.location().page() >= first-page)
+ let slides = query(<touying-metadata>).filter(it => (
+ utils.is-kind(it, "touying-new-slide")
+ and it.location().page() >= first-page
+ ))
+ let current-page = here().page()
+ let current-index = (
+ sections.filter(it => it.location().page() <= current-page).len() - 1
+ )
+ let cols = ()
+ let col = ()
+ for (hd, next-hd) in headings.zip(headings.slice(1) + (none,)) {
+ let next-page = if next-hd != none {
+ next-hd.location().page()
+ } else {
+ calc.inf
+ }
+ if hd.level == 1 {
+ if col != () {
+ cols.push(align(left, col.sum()))
+ col = ()
+ }
+ col.push({
+ let body = if short-heading {
+ utils.short-heading(self: self, hd)
+ } else {
+ hd.body
+ }
+ [#link(hd.location(), body)<touying-link>]
+ if inline {
+ h(.5em)
+ } else {
+ linebreak()
+ }
+ while (
+ slides.len() > 0 and slides.at(0).location().page() < next-page
+ ) {
+ let slide = slides.remove(0)
+ if display-section {
+ let next-slide-page = if slides.len() > 0 {
+ slides.at(0).location().page()
+ } else {
+ calc.inf
+ }
+ if (
+ slide.location().page() <= current-page
+ and current-page < next-slide-page
+ ) {
+ [#link(slide.location(), sym.circle.filled)<touying-link>]
+ } else {
+ [#link(slide.location(), sym.circle.small)<touying-link>]
+ }
+ }
+ }
+ if display-section and display-subsection and linebreaks {
+ linebreak()
+ }
+ })
+ } else {
+ col.push({
+ while (
+ slides.len() > 0 and slides.at(0).location().page() < next-page
+ ) {
+ let slide = slides.remove(0)
+ if display-subsection {
+ let next-slide-page = if slides.len() > 0 {
+ slides.at(0).location().page()
+ } else {
+ calc.inf
+ }
+ if (
+ slide.location().page() <= current-page
+ and current-page < next-slide-page
+ ) {
+ [#link(slide.location(), sym.circle.filled)<touying-link>]
+ } else {
+ [#link(slide.location(), sym.circle.small)<touying-link>]
+ }
+ }
+ }
+ if display-subsection and linebreaks {
+ linebreak()
+ }
+ })
+ }
+ }
+ if col != () {
+ cols.push(align(left, col.sum()))
+ col = ()
+ }
+ if current-index < 0 or current-index >= cols.len() {
+ cols = cols.map(body => text(fill: fill, body))
+ } else {
+ cols = cols
+ .enumerate()
+ .map(pair => {
+ let (idx, body) = pair
+ if idx == current-index {
+ text(fill: fill, body)
+ } else {
+ text(fill: utils.update-alpha(fill, alpha), body)
+ }
+ })
+ }
+ set align(top)
+ show: block.with(inset: (top: .5em, x: if inline { 1em } else { 2em }))
+ show linebreak: it => it + v(-1em)
+ set text(size: .7em)
+ grid(columns: cols.map(_ => auto).intersperse(1fr), ..cols.intersperse([]))
+ }
+)
+
+
+/// A horizontal navigation bar showing all level-1 sections as clickable links.
+///
+/// The active section label is shown in `primary` color; all other sections use `secondary` color. An optional logo is placed at the right edge. Typically used as a page header in themes.
+///
+/// - self (none): The self context, used to resolve short headings.
+///
+/// - short-heading (bool): Whether to shorten heading labels using `utils.short-heading`. Default is `true`.
+///
+/// - primary (color): The text color of the currently active section. Default is `white`.
+///
+/// - secondary (color): The text color of inactive sections. Default is `gray`.
+///
+/// - background (color): The background fill of the navigation bar. Default is `black`.
+///
+/// - logo (content, none): Optional logo displayed at the right side of the bar. Default is `none`.
+///
+/// -> content
+#let simple-navigation(
+ self: none,
+ short-heading: true,
+ primary: white,
+ secondary: gray,
+ background: black,
+ logo: none,
+) = (
+ context {
+ let body() = {
+ let sections = query(heading.where(level: 1, outlined: true))
+ if sections.len() == 0 {
+ return
+ }
+ let current-page = here().page()
+ set text(size: 0.5em)
+ for (section, next-section) in sections.zip(sections.slice(1) + (none,)) {
+ set text(fill: if section.location().page() <= current-page
+ and (
+ next-section == none
+ or current-page < next-section.location().page()
+ ) {
+ primary
+ } else {
+ secondary
+ })
+ box(inset: 0.5em)[#link(
+ section.location(),
+ if short-heading {
+ utils.short-heading(self: self, section)
+ } else {
+ section.body
+ },
+ )<touying-link>]
+ }
+ }
+ block(
+ fill: background,
+ inset: 0pt,
+ outset: 0pt,
+ grid(
+ align: center + horizon,
+ columns: (1fr, auto),
+ rows: 1.8em,
+ gutter: 0em,
+ cell(
+ fill: background,
+ body(),
+ ),
+ block(fill: background, inset: 4pt, height: 100%, text(
+ fill: primary,
+ logo,
+ )),
+ ),
+ )
+ }
+)
+
+
+/// LaTeX-like knob marker for list items.
+///
+/// Example: `#set list(marker: components.knob-marker(primary: rgb("005bac")))`
+///
+/// - primary (color): The color of the marker.
+///
+/// -> content
+#let knob-marker(primary: rgb("#005bac")) = box(
+ width: 0.5em,
+ place(
+ dy: 0.1em,
+ circle(
+ fill: gradient.radial(
+ primary.lighten(100%),
+ primary.darken(40%),
+ focal-center: (30%, 30%),
+ ),
+ radius: 0.25em,
+ ),
+ ),
+)
+
+/// A non-breakable page container that prevents slide content from overflowing
+/// to the next page. When used, content that exceeds the slide height will be
+/// constrained rather than creating additional pages.
+///
+/// This is useful for ensuring a strict one-to-one mapping between source
+/// slides and output pages, which is important in agentic workflows where
+/// an agent needs to reason about slide boundaries.
+///
+/// - clip (bool): Whether to clip overflowing content. When `true`, content
+/// that exceeds the slide height will be visually truncated. Default is `false`.
+///
+/// - detect-overflow (bool): Whether to detect and warn on overflow. When `true`,
+/// a `layout` + `measure` check is performed and a warning is emitted if the
+/// content height exceeds the available container height. When `false`, no
+/// overflow detection is performed (avoids the `layout` overhead). Default is `false`.
+///
+/// - body (content): The slide content to constrain within a single page.
+///
+/// -> content
+#let page-container(self: none, clip: false, detect-overflow: false, body) = {
+ let tight-block-args = (
+ above: 0pt,
+ below: 0pt,
+ inset: (:),
+ outset: (:),
+ radius: (:),
+ spacing: 0pt,
+ sticky: false,
+ stroke: (:),
+ )
+ if detect-overflow {
+ // Detect and warn on overflow
+ layout(container-size => {
+ let content-size = measure(block(
+ ..tight-block-args,
+ width: container-size.width,
+ body,
+ ))
+ let content-height = content-size.height
+ let available-height = container-size.height
+ if content-height > available-height {
+ warning(
+ "detecting slide content overflow at page "
+ + repr(here().page())
+ + " (slide "
+ + str(utils.slide-counter.get().last())
+ + ", subslide "
+ + str(self.subslide)
+ + ", content height: "
+ + repr(content-height)
+ + ", available height: "
+ + repr(available-height)
+ + ").",
+ )
+ } else if content-height == 0pt {
+ warning(
+ "detecting slide content is empty at page "
+ + repr(here().page())
+ + " (slide "
+ + str(utils.slide-counter.get().last())
+ + ", subslide "
+ + str(self.subslide)
+ + ", content height: "
+ + repr(content-height)
+ + ", available height: "
+ + repr(available-height)
+ + ").",
+ )
+ }
+ })
+ }
+ // Disable breakability to prevent overflowing content from creating new pages
+ block(
+ ..tight-block-args,
+ breakable: false,
+ clip: clip,
+ height: 1fr,
+ width: 100%,
+ body,
+ )
+}
--- /dev/null
+#import "pdfpc.typ"
+#import "utils.typ"
+#import "extern.typ"
+#import "core.typ": (
+ slide, touying-fn-wrapper-raw, touying-slide, touying-slide-wrapper,
+)
+
+#let _default = metadata((kind: "touying-default"))
+
+#let _get-dict-without-default(dict) = {
+ let new-dict = (:)
+ for (key, value) in dict.pairs() {
+ if value != _default {
+ new-dict.insert(key, value)
+ }
+ }
+ return new-dict
+}
+
+/// Store theme-specific private data in the presentation context.
+///
+/// Use this in your theme's `#show: my-theme.with(...)` to pass arbitrary key-value pairs that your theme needs internally. The stored values are accessible via `self.store.<key>` inside any theme function.
+///
+/// This also registers your theme's config to be readable by `touying-get-config`.
+///
+/// Example:
+///
+/// ```typst
+/// config-store(
+/// header-height: 2em,
+/// show-logo: true,
+/// )
+/// ```
+///
+/// - args (arguments): Named key-value pairs to store in `self.store`.
+///
+/// -> dictionary
+#let config-store(..args) = {
+ assert(args.pos().len() == 0, message: "Unexpected positional arguments.")
+ return (store: args.named())
+}
+
+
+#let _default-frozen-states = (
+ // ctheorems state
+ state("thm", (
+ "counters": ("heading": ()),
+ "latest": (),
+ )),
+)
+
+#let _default-frozen-counters = (
+ counter(heading),
+ counter(math.equation),
+ counter(figure.where(kind: table)),
+ counter(figure.where(kind: image)),
+)
+
+#let _default-preamble = self => {
+ context {
+ let marks = query(<touying-temporary-mark>)
+ if marks.len() > 0 {
+ let page-num = marks.at(0).location().page()
+ let slide-name = query(selector(heading).before(marks.at(0).location()))
+ .last()
+ .body
+ .text
+ let kind = marks.at(0).value.kind
+ let fn = marks.at(0).value.fn
+ let warning-msg = (
+ "Unsupported mark `"
+ + kind
+ + "` from `"
+ + repr(fn)
+ + "` at page "
+ + str(page-num)
+ + " in section '"
+ + str(slide-name)
+ + "'. You can't use it inside some functions like `context`. You may want to use the callback-style `utils."
+ + repr(fn)
+ + "` function instead."
+ )
+ if self.at("enable-mark-warning", default: true) {
+ panic(warning-msg)
+ } else {
+ extern.warning(warning-msg)
+ }
+ }
+ }
+ if self.at("enable-pdfpc", default: true) {
+ context pdfpc.pdfpc-file(here())
+ }
+ if self.at("show-bibliography-as-footnote", default: none) != none {
+ let args = self.at("show-bibliography-as-footnote", default: none)
+ let bibliography = if type(args) == dictionary {
+ args.at("bibliography")
+ } else {
+ args
+ }
+ place(hide(bibliography))
+ }
+}
+
+#let _default-page-preamble = self => {
+ if self.at("reset-footnote-number-per-slide", default: true) {
+ counter(footnote).update(0)
+ }
+ if self.at("reset-page-counter-to-slide-counter", default: true) {
+ context counter(page).update(calc.max(1, utils.slide-counter.get().first()))
+ }
+ if self.at("enable-pdfpc", default: true) {
+ context [
+ #metadata((t: "NewSlide")) <pdfpc>
+ #metadata((t: "Idx", v: here().page() - 1)) <pdfpc>
+ #metadata((t: "Overlay", v: self.subslide - 1)) <pdfpc>
+ #metadata((
+ t: "LogicalSlide",
+ v: calc.max(1, utils.slide-counter.get().first()),
+ )) <pdfpc>
+ ]
+ }
+}
+
+#let _default-slide-preamble = self => {
+ if self.at("reset-footnote-number-per-slide", default: true) {
+ counter(footnote).update(0)
+ }
+}
+
+
+/// The common configurations of the slides.
+///
+/// - breakable (bool): Whether to allow slide content to overflow to the next page. When `true` (default), content that exceeds the slide height will automatically create new pages. When `false`, content is constrained to a single page using a non-breakable block, which is useful for ensuring a strict one-to-one mapping between source slides and output pages in agentic workflows. Default is `true`.
+///
+/// - clip (bool): Whether to clip overflowing slide content when `breakable` is `false`. When `true`, content that exceeds the slide height will be visually truncated. When `false`, overflowing content remains visible but does not create new pages. Only takes effect when `breakable` is `false`. Default is `false`.
+///
+/// - detect-overflow (bool): Whether to detect and warn on slide content overflow when `breakable` is `false`. When `true`, a layout measurement is performed and a warning is emitted if the content height exceeds the available slide height, which is useful for catching overflow early in agentic workflows without aborting compilation. When `false`, no overflow detection is performed. Only takes effect when `breakable` is `false`. Default is `true`.
+///
+/// - handout (bool): Whether to enable the handout mode. By default, it retains only the last subslide of each slide, but this can be overridden via `handout-subslides`. Default is `false`.
+///
+/// - handout-subslides (none, int, array, str): The subslides to include in handout mode. Accepts the same format as `visible-subslides` (e.g. `2`, `(1, 3)`, `"2-"`, `"1, 3-5"`). When `none`, the last subslide is used (default behavior). Default is `none`.
+///
+/// - slide-level (int): The level of the slides. Default is `2`, which means the level 1 and 2 headings will be treated as slides.
+///
+/// - slide-fn (function): The function to create a new slide.
+///
+/// - new-section-slide-fn (function): The function to create a new slide for a new section. Default is `none`.
+///
+/// - new-subsection-slide-fn (function): The function to create a new slide for a new subsection. Default is `none`.
+///
+/// - new-subsubsection-slide-fn (function): The function to create a new slide for a new subsubsection. Default is `none`.
+///
+/// - new-subsubsubsection-slide-fn (function): The function to create a new slide for a new subsubsubsection. Default is `none`.
+///
+/// - receive-body-for-new-section-slide-fn (bool): Whether to receive the body for the new section slide function. Default is `true`.
+///
+/// - receive-body-for-new-subsection-slide-fn (bool): Whether to receive the body for the new subsection slide function. Default is `true`.
+///
+/// - receive-body-for-new-subsubsection-slide-fn (bool): Whether to receive the body for the new subsubsection slide function. Default is `true`.
+///
+/// - receive-body-for-new-subsubsubsection-slide-fn (bool): Whether to receive the body for the new subsubsubsection slide function. Default is `true`.
+///
+/// - show-strong-with-alert (bool): Whether to show strong with alert. Default is `true`.
+///
+/// - datetime-format (auto, str): The format of the datetime. Default is `auto`.
+///
+/// - appendix (bool): Is touying in the appendix mode. The last-slide-counter will be frozen in the appendix mode. Default is `false`.
+///
+/// - freeze-slide-counter (bool): Whether to freeze the slide counter. Default is `false`.
+///
+/// - zero-margin-header (bool): Whether to show the full header (with negative padding). Default is `true`.
+///
+/// - zero-margin-footer (bool): Whether to show the full footer (with negative padding). Default is `true`.
+///
+/// - auto-offset-for-heading (bool): Whether to add an offset relative to slide-level for headings. Default is `true`.
+///
+/// - enable-pdfpc (bool): Whether to add `<pdfpc-file>` label for querying. Default is `true`.
+///
+/// You can export the .pdfpc file directly using: `typst query --root . ./example.typ --field value --one "<pdfpc-file>" > ./example.pdfpc`
+///
+/// - enable-mark-warning (bool): Whether to enable the mark warning. Default is `true`.
+///
+/// - reset-page-counter-to-slide-counter (bool): Whether to reset the page counter to the slide counter. Default is `true`.
+///
+/// - show-only-notes (bool): Whether to show the speaker notes as the main content with the slide shown as a small thumbnail in the top right corner. Default is `false`.
+///
+/// This is similar to LaTeX Beamer's `\setbeameroption{show only notes}`. It is useful for using speaker notes with presentation tools that let you load two PDFs and synchronize them, one to display on the main screen and one on the auxiliary screen.
+///
+/// - show-notes-on-second-screen (none, alignment): Whether to show the speaker notes on the second screen. Default is `none`.
+///
+/// Currently, the alignment can be `none`, `bottom`, and `right`.
+///
+/// - horizontal-line-to-pagebreak (bool): Whether to convert horizontal lines to page breaks. Default is `true`.
+///
+/// You can use markdown-like syntax `---` to divide slides.
+///
+/// - reset-footnote-number-per-slide (bool): Whether to reset the footnote number per slide. Default is `true`.
+///
+/// - nontight-list-enum-and-terms (bool): Whether to make `tight` argument always be `false` for list, enum, and terms. Default is `false`.
+///
+/// - align-list-marker-with-baseline (bool): Whether to align the list marker with the baseline. Default is `false`.
+///
+/// - align-enum-marker-with-baseline (bool): Whether to align the enum marker with the baseline. Default is `false`. It will only work when the enum item has a number like `1.`.
+///
+/// - scale-list-items (none, float): Whether to scale the list items recursively. For example, `scale-list-items: 0.8` will scale the list items by 0.8. Default is `none`.
+///
+/// - enable-frozen-states-and-counters (bool): Whether to enable the frozen states and counters. It is useful for equations, figures, and theorems. Default is `true`.
+///
+/// - show-hide-set-list-marker-none (bool): Whether to set the list marker to none for hide function. Default is `true`.
+///
+/// - show-bibliography-as-footnote (bool): Whether to show the bibliography as footnote. Default is `none`.
+///
+/// It receives a bibliography function like `bibliography(title: none, "ref.bib")`, or a dict like `(numbering: "[1]", bibliography: bibliography(title: none, "ref.bib"))`.
+///
+/// - frozen-states (array): The frozen states for the frozen states and counters. Default is `()`.
+///
+/// - default-frozen-states (function): The default frozen states for the frozen states and counters. Default is state for `ctheorems` package.
+///
+/// - frozen-counters (array): The frozen counters for the frozen states and counters. You can pass some counters like `(counter(math.equation),)`. Default is `()`.
+///
+/// - default-frozen-counters (array): The default frozen counters for the frozen states and counters. The default value is `(counter(math.equation), counter(figure.where(kind: table)), counter(figure.where(kind: image)))`.
+///
+/// - label-only-on-last-subslide (array): We only label some contents in the last subslide, which is useful for ref equations, figures, footnotes, and theorems with multiple subslides. Default is `(figure, math.equation, footnote)`.
+///
+/// - preamble (function): The function to run before each slide. Default is `none`.
+///
+/// - default-preamble (function): The default preamble for each slide. Default is a function to check the mark warning and add pdfpc file.
+///
+/// - slide-preamble (function): The function to run before each slide. Default is `none`.
+///
+/// - default-slide-preamble (function): The default preamble for each slide. Default is `none`.
+///
+/// - subslide-preamble (function): The function to run before each subslide. Default is `none`.
+///
+/// - default-subslide-preamble (function): The default preamble for each subslide. Default is `none`.
+///
+/// - page-preamble (function): The function to run before each page. Default is `none`.
+///
+/// - default-page-preamble (function): The default preamble for each page. Default is a function to reset the footnote number per slide and reset the page counter to the slide counter.
+///
+/// - default-composer (auto, function, array): The default composer for slides. It is used when the `composer` argument of the `slide` function is `auto`. Default is `auto`, which falls back to using `cols.with(lazy-layout: false)`.
+///
+/// For example, `config-common(default-composer: cols.with(lazy-layout: false, gutter: 2em))` sets the default gutter between columns to `2em` for all slides.
+///
+/// -> dictionary
+#let config-common(
+ breakable: _default,
+ clip: _default,
+ detect-overflow: _default,
+ handout: _default,
+ handout-subslides: _default,
+ slide-level: _default,
+ slide-fn: _default,
+ new-section-slide-fn: _default,
+ new-subsection-slide-fn: _default,
+ new-subsubsection-slide-fn: _default,
+ new-subsubsubsection-slide-fn: _default,
+ receive-body-for-new-section-slide-fn: _default,
+ receive-body-for-new-subsection-slide-fn: _default,
+ receive-body-for-new-subsubsection-slide-fn: _default,
+ receive-body-for-new-subsubsubsection-slide-fn: _default,
+ show-strong-with-alert: _default,
+ datetime-format: _default,
+ appendix: _default,
+ freeze-slide-counter: _default,
+ zero-margin-header: _default,
+ zero-margin-footer: _default,
+ auto-offset-for-heading: _default,
+ enable-pdfpc: _default,
+ enable-mark-warning: _default,
+ reset-page-counter-to-slide-counter: _default,
+ // some black magics for better slides writing,
+ // maybe will be deprecated in the future
+ enable-frozen-states-and-counters: _default,
+ frozen-states: _default,
+ default-frozen-states: _default,
+ frozen-counters: _default,
+ default-frozen-counters: _default,
+ label-only-on-last-subslide: _default,
+ preamble: _default,
+ default-preamble: _default,
+ slide-preamble: _default,
+ default-slide-preamble: _default,
+ subslide-preamble: _default,
+ default-subslide-preamble: _default,
+ page-preamble: _default,
+ default-page-preamble: _default,
+ show-only-notes: _default,
+ show-notes-on-second-screen: _default,
+ horizontal-line-to-pagebreak: _default,
+ reset-footnote-number-per-slide: _default,
+ nontight-list-enum-and-terms: _default,
+ align-list-marker-with-baseline: _default,
+ align-enum-marker-with-baseline: _default,
+ scale-list-items: _default,
+ show-hide-set-list-marker-none: _default,
+ show-bibliography-as-footnote: _default,
+ default-composer: _default,
+ ..args,
+) = {
+ assert(args.pos().len() == 0, message: "Unexpected positional arguments.")
+ return (
+ _get-dict-without-default((
+ breakable: breakable,
+ clip: clip,
+ detect-overflow: detect-overflow,
+ handout: handout,
+ handout-subslides: handout-subslides,
+ slide-level: slide-level,
+ slide-fn: slide-fn,
+ new-section-slide-fn: new-section-slide-fn,
+ new-subsection-slide-fn: new-subsection-slide-fn,
+ new-subsubsection-slide-fn: new-subsubsection-slide-fn,
+ new-subsubsubsection-slide-fn: new-subsubsubsection-slide-fn,
+ receive-body-for-new-section-slide-fn: receive-body-for-new-section-slide-fn,
+ receive-body-for-new-subsection-slide-fn: receive-body-for-new-subsection-slide-fn,
+ receive-body-for-new-subsubsection-slide-fn: receive-body-for-new-subsubsection-slide-fn,
+ receive-body-for-new-subsubsubsection-slide-fn: receive-body-for-new-subsubsubsection-slide-fn,
+ show-strong-with-alert: show-strong-with-alert,
+ datetime-format: datetime-format,
+ appendix: appendix,
+ freeze-slide-counter: freeze-slide-counter,
+ zero-margin-header: zero-margin-header,
+ zero-margin-footer: zero-margin-footer,
+ auto-offset-for-heading: auto-offset-for-heading,
+ enable-pdfpc: enable-pdfpc,
+ enable-mark-warning: enable-mark-warning,
+ reset-page-counter-to-slide-counter: reset-page-counter-to-slide-counter,
+ enable-frozen-states-and-counters: enable-frozen-states-and-counters,
+ frozen-states: frozen-states,
+ frozen-counters: frozen-counters,
+ default-frozen-states: default-frozen-states,
+ default-frozen-counters: default-frozen-counters,
+ label-only-on-last-subslide: label-only-on-last-subslide,
+ preamble: preamble,
+ default-preamble: default-preamble,
+ slide-preamble: slide-preamble,
+ default-slide-preamble: default-slide-preamble,
+ subslide-preamble: subslide-preamble,
+ default-subslide-preamble: default-subslide-preamble,
+ page-preamble: page-preamble,
+ default-page-preamble: default-page-preamble,
+ show-only-notes: show-only-notes,
+ show-notes-on-second-screen: show-notes-on-second-screen,
+ horizontal-line-to-pagebreak: horizontal-line-to-pagebreak,
+ reset-footnote-number-per-slide: reset-footnote-number-per-slide,
+ nontight-list-enum-and-terms: nontight-list-enum-and-terms,
+ align-list-marker-with-baseline: align-list-marker-with-baseline,
+ align-enum-marker-with-baseline: align-enum-marker-with-baseline,
+ scale-list-items: scale-list-items,
+ show-hide-set-list-marker-none: show-hide-set-list-marker-none,
+ show-bibliography-as-footnote: show-bibliography-as-footnote,
+ default-composer: default-composer,
+ ))
+ + args.named()
+ )
+}
+
+
+#let _default-init(self: none, body) = {
+ body
+}
+
+#let _default-cover = utils.method-wrapper(hide)
+
+#let _default-show-only-notes(
+ self: none,
+ width: 0pt,
+ height: 0pt,
+ cutout: false,
+) = {
+ let header-fill = rgb("#CCCCCC")
+ let header-height = 88pt
+ let header-content = {
+ utils.display-current-heading(level: 1, depth: self.slide-level)
+ linebreak()
+ [ --- ]
+ utils.display-current-heading(level: 2, depth: self.slide-level)
+ }
+ let body-fill = rgb("#E6E6E6")
+ let body-content = {
+ pad(x: 48pt, utils.current-slide-note)
+ // clear the slide note
+ utils.slide-note-state.update(none)
+ }
+
+ let template(hdr-fill, hdr-content, bdy-fill, bdy-content) = block(
+ fill: bdy-fill,
+ width: width,
+ height: height,
+ {
+ set align(left + top)
+ set text(size: 24pt, fill: black, weight: "regular")
+ block(
+ width: 100%,
+ height: header-height,
+ inset: (left: 32pt, top: 16pt),
+ outset: 0pt,
+ fill: hdr-fill,
+ hdr-content,
+ )
+ bdy-content
+ },
+ )
+
+ if cutout {
+ (
+ background: template(header-fill, none, body-fill, none),
+ foreground: template(none, header-content, none, body-content),
+ cutout-height: header-height,
+ )
+ } else {
+ template(header-fill, header-content, body-fill, body-content)
+ }
+}
+
+#let _default-alert = utils.method-wrapper(text.with(weight: "bold"))
+
+#let _default-convert-label-to-short-heading(self: none, lbl) = utils.titlecase(
+ lbl.replace(regex("^[^:]*:"), "").replace("_", " ").replace("-", " "),
+)
+
+/// The configuration of the methods.
+///
+/// - init (function): The function to initialize the presentation. It should be `(self: none, body) => { .. }`.
+///
+/// - cover (function): The function to cover content. The default value is `utils.method-wrapper(hide)` function.
+///
+/// You can configure it with `cover: utils.semi-transparent-cover` to use the semi-transparent cover.
+///
+/// - uncover (function): The function to uncover content. The default value is `utils.uncover` function.
+///
+/// - only (function): The function to show only the content. The default value is `utils.only` function.
+///
+/// - effect (function): The function to add effect to the content. The default value is `utils.effect`.
+///
+/// - alternatives-match (function): The function to match alternatives. The default value is `utils.alternatives-match` function.
+///
+/// - alternatives (function): The function to show alternatives. The default value is `utils.alternatives` function.
+///
+/// - alternatives-fn (function): The function to show alternatives with a function. The default value is `utils.alternatives-fn` function.
+///
+/// - alternatives-cases (function): The function to show alternatives with cases. The default value is `utils.alternatives-cases` function.
+///
+/// - item-by-item (function): The function to show items one by one. The default value is `utils.item-by-item` function.
+///
+/// - alert (function): The function to alert the content. The default value is `utils.method-wrapper(text.with(weight: "bold"))` function.
+///
+/// - show-only-notes (function): The function used to render speaker notes, either as the primary content (`show-only-notes: true` mode) or on a second screen. It should accept `(self: none, width: 0pt, height: 0pt, cutout: false)`. When `cutout: true`, return a dictionary with `background`, `foreground`, and `cutout-height` keys.
+///
+/// - convert-label-to-short-heading (function): The function to convert label to short heading. It is useful for the short heading for heading with label. It will be used in function with `short-heading`.
+///
+/// The default value is `utils.titlecase(lbl.replace(regex("^[^:]*:"), "").replace("_", " ").replace("-", " "))`.
+///
+/// It means that some headings with labels like `section:my-section` will be converted to `My Section`.
+///
+/// -> dictionary
+#let config-methods(
+ // init
+ init: _default,
+ cover: _default,
+ // dynamic control
+ uncover: _default,
+ only: _default,
+ effect: _default,
+ alternatives-match: _default,
+ alternatives: _default,
+ alternatives-fn: _default,
+ alternatives-cases: _default,
+ item-by-item: _default,
+ // alert interface
+ alert: _default,
+ // show notes
+ show-only-notes: _default,
+ // convert label to short heading
+ convert-label-to-short-heading: _default,
+ ..args,
+) = {
+ assert(args.pos().len() == 0, message: "Unexpected positional arguments.")
+ return (
+ methods: _get-dict-without-default((
+ init: init,
+ cover: cover,
+ uncover: uncover,
+ only: only,
+ effect: effect,
+ alternatives-match: alternatives-match,
+ alternatives: alternatives,
+ alternatives-fn: alternatives-fn,
+ alternatives-cases: alternatives-cases,
+ item-by-item: item-by-item,
+ alert: alert,
+ show-only-notes: show-only-notes,
+ convert-label-to-short-heading: convert-label-to-short-heading,
+ ))
+ + args.named(),
+ )
+}
+
+
+/// The configuration of important information of the presentation.
+///
+/// Example:
+///
+/// ```typst
+/// config-info(
+/// title: "Title",
+/// subtitle: "Subtitle",
+/// author: "Author",
+/// date: datetime.today(),
+/// institution: "Institution",
+/// contact: "name@mail.com",
+/// )
+/// ```
+///
+/// - title (content): The title of the presentation, which will be displayed in the title slide.
+/// - short-title (content, auto): The short title of the presentation, which will usually be displayed in the footer of the slides.
+///
+/// If you set it to `auto`, it will be the same as the title.
+///
+/// - subtitle (content): The subtitle of the presentation.
+///
+/// - short-subtitle (content, auto): The short subtitle of the presentation, which will usually be displayed in the footer of the slides.
+///
+/// If you set it to `auto`, it will be the same as the subtitle.
+///
+/// - author (content): The author of the presentation.
+///
+/// - date (datetime, content): The date of the presentation.
+///
+/// You can use `datetime.today()` to get the current date.
+///
+/// - institution (content): The institution of the presentation.
+///
+/// - contact (content): Contact information for the presentation.
+///
+/// - logo (content): The logo of the institution.
+///
+/// - extra (dict): A dict of extra information. You may use it like `extra: (key1: value1, key2: value2)` to pass extra information. A theme can then access it as `self.info.extra.key1`, `self.info.extra.key2`.
+///
+/// -> dictionary
+#let config-info(
+ title: _default,
+ short-title: _default,
+ subtitle: _default,
+ short-subtitle: _default,
+ author: _default,
+ date: _default,
+ institution: _default,
+ contact: _default,
+ logo: _default,
+ extra: _default,
+ ..args,
+) = {
+ assert(args.pos().len() == 0, message: "Unexpected positional arguments.")
+ return (
+ info: _get-dict-without-default((
+ title: title,
+ short-title: short-title,
+ subtitle: subtitle,
+ short-subtitle: short-subtitle,
+ author: author,
+ date: date,
+ institution: institution,
+ contact: contact,
+ logo: logo,
+ extra: extra,
+ ))
+ + args.named(),
+ )
+}
+
+
+/// The configuration of the colors used in the theme.
+///
+/// Example:
+///
+/// ```typst
+/// config-colors(
+/// primary: rgb("#04364A"),
+/// secondary: rgb("#176B87"),
+/// tertiary: rgb("#448C95"),
+/// neutral: rgb("#303030"),
+/// neutral-darkest: rgb("#000000"),
+/// )
+/// ```
+///
+/// IMPORTANT: The colors should be defined in the *RGB* format at most cases.
+///
+/// There are four main color groups: `primary`, `secondary`, `tertiary`, and `neutral`.
+/// Each group includes the base color plus variants: `light`, `lighter`, `lightest`, `dark`, `darker`, `darkest`.
+/// For example, `primary`, `primary-light`, `primary-lightest`, `neutral-darkest`, etc.
+///
+/// -> dictionary
+#let config-colors(
+ neutral: _default,
+ neutral-light: _default,
+ neutral-lighter: _default,
+ neutral-lightest: _default,
+ neutral-dark: _default,
+ neutral-darker: _default,
+ neutral-darkest: _default,
+ primary: _default,
+ primary-light: _default,
+ primary-lighter: _default,
+ primary-lightest: _default,
+ primary-dark: _default,
+ primary-darker: _default,
+ primary-darkest: _default,
+ secondary: _default,
+ secondary-light: _default,
+ secondary-lighter: _default,
+ secondary-lightest: _default,
+ secondary-dark: _default,
+ secondary-darker: _default,
+ secondary-darkest: _default,
+ tertiary: _default,
+ tertiary-light: _default,
+ tertiary-lighter: _default,
+ tertiary-lightest: _default,
+ tertiary-dark: _default,
+ tertiary-darker: _default,
+ tertiary-darkest: _default,
+ ..args,
+) = {
+ assert(args.pos().len() == 0, message: "Unexpected positional arguments.")
+ return (
+ colors: _get-dict-without-default((
+ neutral: neutral,
+ neutral-light: neutral-light,
+ neutral-lighter: neutral-lighter,
+ neutral-lightest: neutral-lightest,
+ neutral-dark: neutral-dark,
+ neutral-darker: neutral-darker,
+ neutral-darkest: neutral-darkest,
+ primary: primary,
+ primary-light: primary-light,
+ primary-lighter: primary-lighter,
+ primary-lightest: primary-lightest,
+ primary-dark: primary-dark,
+ primary-darker: primary-darker,
+ primary-darkest: primary-darkest,
+ secondary: secondary,
+ secondary-light: secondary-light,
+ secondary-lighter: secondary-lighter,
+ secondary-lightest: secondary-lightest,
+ secondary-dark: secondary-dark,
+ secondary-darker: secondary-darker,
+ secondary-darkest: secondary-darkest,
+ tertiary: tertiary,
+ tertiary-light: tertiary-light,
+ tertiary-lighter: tertiary-lighter,
+ tertiary-lightest: tertiary-lightest,
+ tertiary-dark: tertiary-dark,
+ tertiary-darker: tertiary-darker,
+ tertiary-darkest: tertiary-darkest,
+ ))
+ + args.named(),
+ )
+}
+
+/// The configuration of the page layout.
+///
+/// It is equivalent to the `#set page()` rule in Touying.
+///
+/// Example:
+///
+/// ```typst
+/// config-page(
+/// paper: "presentation-16-9",
+/// header: none,
+/// footer: none,
+/// fill: rgb("#ffffff"),
+/// margin: (x: 3em, y: 2.8em),
+/// )
+/// ```
+///
+/// - paper (str): A standard paper size to set width and height. The default value is `"presentation-16-9"`.
+///
+/// You can also use `aspect-ratio` to set the aspect ratio of the paper.
+///
+/// - header (content): The page's header. Fills the top margin of each page.
+///
+/// - footer (content): The page's footer. Fills the bottom margin of each page.
+///
+/// - fill (color): The background color of the page. The default value is `rgb("#ffffff")`.
+///
+/// - margin (length, dictionary): The margin of the page. The default value is `(x: 3em, y: 2.8em)`.
+/// - A single length: The same margin on all sides.
+/// - A dictionary: With a dictionary, the margins can be set individually. The dictionary can contain the following keys in order of precedence:
+/// - top: The top margin.
+/// - right: The right margin.
+/// - bottom: The bottom margin.
+/// - left: The left margin.
+/// - inside: The margin at the inner side of the page (where the binding is).
+/// - outside: The margin at the outer side of the page (opposite to the binding).
+/// - x: The horizontal margins.
+/// - y: The vertical margins.
+/// - rest: The margins on all sides except those for which the dictionary explicitly sets a size.
+///
+/// - numbering (str, function): The numbering style of the page. The default value is `"1"`.
+///
+/// The values for left and right are mutually exclusive with the values for inside and outside.
+///
+/// -> dictionary
+#let config-page(
+ paper: _default,
+ header: _default,
+ footer: _default,
+ fill: _default,
+ margin: _default,
+ numbering: _default,
+ ..args,
+) = {
+ assert(args.pos().len() == 0, message: "Unexpected positional arguments.")
+ return (
+ page: _get-dict-without-default((
+ paper: paper,
+ header: header,
+ footer: footer,
+ fill: fill,
+ margin: margin,
+ numbering: numbering,
+ ))
+ + args.named(),
+ )
+}
+
+
+/// The default configuration values used when no explicit configuration is provided.
+#let default-config = utils.merge-dicts(
+ config-common(
+ breakable: true,
+ clip: false,
+ detect-overflow: true,
+ handout: false,
+ handout-subslides: none,
+ slide-level: 2,
+ slide-fn: slide,
+ new-section-slide-fn: none,
+ new-subsection-slide-fn: none,
+ new-subsubsection-slide-fn: none,
+ new-subsubsubsection-slide-fn: none,
+ receive-body-for-new-section-slide-fn: false,
+ receive-body-for-new-subsection-slide-fn: false,
+ receive-body-for-new-subsubsection-slide-fn: false,
+ receive-body-for-new-subsubsubsection-slide-fn: false,
+ show-strong-with-alert: true,
+ datetime-format: auto,
+ appendix: false,
+ freeze-slide-counter: false,
+ zero-margin-header: true,
+ zero-margin-footer: true,
+ auto-offset-for-heading: false,
+ enable-pdfpc: true,
+ enable-mark-warning: true,
+ reset-page-counter-to-slide-counter: true,
+ // some black magics for better slides writing,
+ // maybe will be deprecated in the future
+ show-only-notes: false,
+ show-notes-on-second-screen: none,
+ horizontal-line-to-pagebreak: true,
+ reset-footnote-number-per-slide: true,
+ nontight-list-enum-and-terms: false,
+ align-list-marker-with-baseline: false,
+ align-enum-marker-with-baseline: false,
+ scale-list-items: none,
+ show-hide-set-list-marker-none: true,
+ show-bibliography-as-footnote: none,
+ enable-frozen-states-and-counters: true,
+ frozen-states: (),
+ default-frozen-states: _default-frozen-states,
+ frozen-counters: (),
+ default-frozen-counters: _default-frozen-counters,
+ label-only-on-last-subslide: (figure, math.equation, heading, footnote),
+ preamble: none,
+ default-preamble: _default-preamble,
+ slide-preamble: none,
+ default-slide-preamble: _default-slide-preamble,
+ subslide-preamble: none,
+ default-subslide-preamble: none,
+ page-preamble: none,
+ default-page-preamble: _default-page-preamble,
+ ),
+ config-methods(
+ // init
+ init: _default-init,
+ cover: _default-cover,
+ // dynamic control
+ uncover: utils.uncover,
+ only: utils.only,
+ effect: utils.effect,
+ alternatives-match: utils.alternatives-match,
+ alternatives: utils.alternatives,
+ alternatives-fn: utils.alternatives-fn,
+ alternatives-cases: utils.alternatives-cases,
+ item-by-item: utils.item-by-item,
+ // alert interface
+ alert: _default-alert,
+ // show notes
+ show-only-notes: _default-show-only-notes,
+ // convert label to short heading
+ convert-label-to-short-heading: _default-convert-label-to-short-heading,
+ ),
+ config-info(
+ title: none,
+ short-title: auto,
+ subtitle: none,
+ short-subtitle: auto,
+ author: none,
+ date: none,
+ institution: none,
+ contact: none,
+ logo: none,
+ extra: (:),
+ ),
+ config-colors(
+ neutral: rgb("#303030"),
+ neutral-light: rgb("#a0a0a0"),
+ neutral-lighter: rgb("#d0d0d0"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-dark: rgb("#202020"),
+ neutral-darker: rgb("#101010"),
+ neutral-darkest: rgb("#000000"),
+ primary: rgb("#303030"),
+ primary-light: rgb("#a0a0a0"),
+ primary-lighter: rgb("#d0d0d0"),
+ primary-lightest: rgb("#ffffff"),
+ primary-dark: rgb("#202020"),
+ primary-darker: rgb("#101010"),
+ primary-darkest: rgb("#000000"),
+ secondary: rgb("#303030"),
+ secondary-light: rgb("#a0a0a0"),
+ secondary-lighter: rgb("#d0d0d0"),
+ secondary-lightest: rgb("#ffffff"),
+ secondary-dark: rgb("#202020"),
+ secondary-darker: rgb("#101010"),
+ secondary-darkest: rgb("#000000"),
+ tertiary: rgb("#303030"),
+ tertiary-light: rgb("#a0a0a0"),
+ tertiary-lighter: rgb("#d0d0d0"),
+ tertiary-lightest: rgb("#ffffff"),
+ tertiary-dark: rgb("#202020"),
+ tertiary-darker: rgb("#101010"),
+ tertiary-darkest: rgb("#000000"),
+ ),
+ config-page(
+ paper: "presentation-16-9",
+ header: none,
+ footer: none,
+ margin: (x: 3em, y: 2.8em),
+ numbering: "1",
+ ),
+ config-store(),
+)
+
+/// Gets the current config at the point of the call. Returns a dict with context evaluated values.
+///
+/// Usage:
+/// ```typc
+/// touying-get-config() // returns the whole config dict
+/// touying-get-config().common.handout // returns the value of the "handout" config in the "common" category
+/// touying-get-config().handout // same as above. common is also registered at the top level.
+/// touying-get-config("commmon.handout") // same as above. You can also query with a key, and you will get the subconfig or value back.
+/// ```
+///
+/// - key (str): The key of the subconfiguration to retrieve. Default `none`, returns the entire config. May also be passed in as a positional argument.
+/// Only necessary when you set custom keys into touyings' config. Theme configuration is naturally available as `touying-get-config().store.long-theme-key`
+/// - default (any): The default value to return if the key is not found.
+/// -> dict
+#let touying-get-config(key: none, default: type, ..args) = {
+ assert(
+ args.pos().len() <= 1,
+ message: "Only one positional argument is allowed.",
+ )
+ assert(
+ args.named().len() == 0,
+ message: "Unexpected named arguments: " + args.named().keys().join(", "),
+ )
+ assert(
+ type(key) == str or key == none,
+ message: "Key must be a string or none.",
+ )
+ let key_ = key
+ if args.pos().len() == 1 {
+ key_ = args.pos().first()
+ }
+
+ let rec-defer-retrieval(config, keychain, default: type) = {
+ if type(config) == dictionary {
+ let defered = (:)
+ for k in config.keys() {
+ defered.insert(k, rec-defer-retrieval(
+ config.at(k),
+ keychain + (k,),
+ default: default,
+ ))
+ }
+ return defered
+ } else {
+ //when we reached a leaf value in the config, we evaluate it at context time via keychain.
+ return touying-fn-wrapper-raw((self: none) => {
+ let value = self
+ for cur in keychain {
+ if cur not in value.keys() and default != type {
+ return default
+ }
+ value = value.at(cur)
+ }
+ return repr(value)
+ })
+ }
+ }
+
+ let rec-defer-retrieval-with-key(
+ config,
+ keychain,
+ key: none,
+ default: type,
+ ) = {
+ if type(config) == dictionary {
+ if key != none {
+ let first-dot = key.position(".")
+ let this-key = none
+ let rest-key = none
+ if first-dot == none {
+ this-key = key
+ rest-key = none
+ } else {
+ this-key = key.slice(0, first-dot)
+ rest-key = key.slice(first-dot + 1)
+ }
+ if this-key in config.keys() {
+ return rec-defer-retrieval-with-key(
+ config.at(this-key),
+ keychain + (this-key,),
+ key: rest-key,
+ default: default,
+ )
+ } else {
+ // store on keychain anyway, but set config to none,
+ // we use the fn-wrapper to try to retrieve the value instead at context time
+ // will likely break, but who knows what users do.
+ let rest-keychain = if rest-key != none {
+ rest-key.split(".")
+ } else {
+ ()
+ }
+ return rec-defer-retrieval(
+ none,
+ keychain + (this-key,) + rest-keychain,
+ default: default,
+ )
+ }
+ } else {
+ // key == none, got to the end of given key, before found a leaf value. returns the deferred unresolved config
+ return rec-defer-retrieval(config, keychain, default: default)
+ }
+ } else if key == none {
+ // found a value and key is also empty, returned deferred result
+ return rec-defer-retrieval(none, keychain, default: default)
+ } else {
+ // got a leaf value, but key is not empty.
+ if default != type {
+ return default
+ }
+ panic(
+ "Key not found in config. Resolved correctly: "
+ + keychain.join(".")
+ + ", but got unresolved part: "
+ + key,
+ )
+ }
+ }
+
+ if key_ == none {
+ return rec-defer-retrieval(default-config, (), default: default)
+ } else {
+ return rec-defer-retrieval-with-key(
+ default-config,
+ (),
+ key: key_,
+ default: default,
+ )
+ }
+}
--- /dev/null
+#import "utils.typ"
+#import "pdfpc.typ"
+#import "components.typ"
+
+/// ------------------------------------------------
+/// Slides
+/// ------------------------------------------------
+
+/// -> content
+#let _delayed-wrapper(body) = [#metadata((
+ kind: "touying-delayed-wrapper",
+ body: body,
+))<touying-temporary-mark>]
+
+/// Inject configuration changes into a block of content. Changes take effect only within `body`. Useful for implementing features like `#show: appendix` or changing the cover method on the fly.
+///
+/// Example: `#let appendix(body) = touying-set-config((appendix: true), defer:true, body)` and you can use `#show: appendix` to set the appendix for the presentation.
+///
+/// The keyword `defer` is useful for features like `appendix`, which should not take effect until the next slide starts. All following content is also then wrapped into the preamble, e.g. `counter-update`, set or other show rules. Putting content after a deferred config show rule will not render it visibly in the document.
+/// You may even use `#show: touying-set-config.with((preamble: fn), defer:true)` to set a preamble function for the next slide or do similar config changes without calling the `slide` function explicitly.
+/// Just relying on the deferred config change's ability to capture functions and running them also in the preamble is considered an antipattern.
+///
+/// - config (dictionary): The new configurations for the presentation.
+///
+/// - defer (bool): Whether to defer applying the config changes until the next slides preamble. Default is false (apply immediately).
+///
+/// - body (content): The content of the slide.
+///
+/// -> content
+#let touying-set-config(config, defer: false, body) = [#metadata((
+ kind: "touying-set-config",
+ config: config,
+ defer: defer,
+ body: body,
+))<touying-temporary-mark>]
+
+//get-config is in src/config.typ as we need the default-config.
+
+
+/// Begin the appendix of the presentation. The slide counter is frozen at the last non-appendix slide, so appendix slides do not affect the total slide count shown in footers.
+///
+/// Equivalent to `#show: touying-set-config.with((appendix: true), defer: true)`.
+///
+/// Example: `#show: appendix`
+///
+/// - body (content): The content of the appendix.
+///
+/// -> content
+#let appendix(body) = touying-set-config(
+ (appendix: true),
+ body,
+ defer: true,
+)
+
+
+/// Recall a slide by its label.
+///
+/// Example:
+///
+/// ```typ
+/// // Recall all subslides
+/// #touying-recall(<my-slide>)
+///
+/// // Recall only a specific subslide
+/// #touying-recall(<my-slide>, subslide: 2)
+///
+/// // Recall only the last (final) subslide
+/// #touying-recall(<my-slide>, subslide: auto)
+///
+/// // Recall the last subslide of every waypoint
+/// #touying-recall(<my-slide>, subslide: "waypoints")
+///
+/// // Recall the subslides covered by a waypoint
+/// #touying-recall(<my-slide>, subslide: <my-waypoint>)
+///
+/// // Recall only the last subslide of a waypoint
+/// #touying-recall(<my-slide>, subslide: get-last(<my-waypoint>))
+/// ```
+///
+/// - lbl (str, label): The label of the slide to recall.
+///
+/// - subslide (none, auto, int, str, label, dictionary): Which subslide(s) to recall.
+///
+/// - `none` (default): recall all subslides.
+/// - `auto`: recall only the last subslide (the final animation state).
+/// - `int`: recall a specific subslide by number.
+/// - `"waypoints"`: recall one subslide per waypoint — specifically, the
+/// last subslide of each waypoint. This shows every animation phase at
+/// its final state.
+/// - `label`: recall the subslides covered by a waypoint in the original
+/// slide. E.g. `subslide: <my-waypoint>`.
+/// - Waypoint marker: `get-first(<wp>)`, `get-last(<wp>)`, `prev-wp(<wp>)`,
+/// `next-wp(<wp>)` — resolves to a single subslide or waypoint range
+/// using the recalled slide's waypoint map.
+///
+/// -> content
+#let touying-recall(lbl, subslide: none) = [#metadata((
+ kind: "touying-slide-recaller",
+ label: if type(lbl) == label {
+ str(lbl)
+ } else {
+ lbl
+ },
+ subslide: subslide,
+))<touying-temporary-mark>]
+
+#let _get-last-heading-depth(current-headings) = {
+ if current-headings != () {
+ current-headings.at(-1).depth
+ } else {
+ 0
+ }
+}
+
+#let _get-last-heading-label(current-headings) = {
+ if current-headings != () {
+ if current-headings.at(-1).has("label") {
+ str(current-headings.at(-1).label)
+ }
+ }
+}
+
+// Get the appropriate slide function based on current heading context
+//
+// - self (dictionary): The presentation context
+// - default (function): Default slide function to use if no specific one matches
+//
+// -> function
+#let _get-slide-fn(self, default: auto) = {
+ let last-heading-depth = _get-last-heading-depth(self.headings)
+ let last-heading-label = _get-last-heading-label(self.headings)
+ if last-heading-label in ("touying:hidden", "touying:skip") {
+ return if default == auto {
+ self.slide-fn
+ } else {
+ default
+ }
+ }
+ if last-heading-depth == 1 and self.new-section-slide-fn != none {
+ self.new-section-slide-fn
+ } else if last-heading-depth == 2 and self.new-subsection-slide-fn != none {
+ self.new-subsection-slide-fn
+ } else if (
+ last-heading-depth == 3 and self.new-subsubsection-slide-fn != none
+ ) {
+ self.new-subsubsection-slide-fn
+ } else if (
+ last-heading-depth == 4 and self.new-subsubsubsection-slide-fn != none
+ ) {
+ self.new-subsubsubsection-slide-fn
+ } else {
+ if default == auto {
+ self.slide-fn
+ } else {
+ default
+ }
+ }
+}
+
+// Call the appropriate slide function with the given body content
+//
+// - self (dictionary): The presentation context
+// - fn (function): The slide function to use (auto to determine automatically)
+// - body (content): The slide content to render
+//
+// -> content
+#let _call-slide-fn(self, fn, body) = {
+ let slide-fn = if fn == auto {
+ _get-slide-fn(self)
+ } else {
+ fn
+ }
+ let slide-wrapper = slide-fn(body)
+ assert(
+ utils.is-kind(slide-wrapper, "touying-slide-wrapper"),
+ message: "you must use `touying-slide-wrapper` in your slide function",
+ )
+ return ((slide-wrapper.value.fn)(self), slide-wrapper.value.fn)
+}
+
+
+// Use headings to split a content block into slides
+//
+// This function recursively processes content and splits it into individual slides
+// based on heading levels and other slide-breaking elements like pagebreaks.
+//
+// - self (dictionary): The presentation context containing slide configuration
+// - recaller-map (dictionary): Map of slide labels to their content for recall functionality
+// - new-start (bool): Whether this is the start of a new slide section
+// - is-first-slide (bool): Whether this is the first slide of the presentation
+// - absorb-leading-preamble (bool): Whether to include the preamble content from before the next split
+// - body (content): The content to be split into slides
+//
+// -> content
+#let split-content-into-slides(
+ self: none,
+ recaller-map: (:),
+ new-start: true,
+ is-first-slide: false,
+ absorb-leading-preamble: false,
+ body,
+) = {
+ // Extract arguments
+ assert(type(self) == dictionary, message: "`self` must be a dictionary")
+ assert(
+ "slide-level" in self and type(self.slide-level) == int,
+ message: "`self.slide-level` must be an integer",
+ )
+ assert(
+ "slide-fn" in self and type(self.slide-fn) == function,
+ message: "`self.slide-fn` must be a function",
+ )
+ let slide-level = self.slide-level
+ let slide-fn = auto
+ let new-section-slide-fn = self.at("new-section-slide-fn", default: none)
+ let new-subsection-slide-fn = self.at(
+ "new-subsection-slide-fn",
+ default: none,
+ )
+ let new-subsubsection-slide-fn = self.at(
+ "new-subsubsection-slide-fn",
+ default: none,
+ )
+ let new-subsubsubsection-slide-fn = self.at(
+ "new-subsubsubsection-slide-fn",
+ default: none,
+ )
+ let horizontal-line-to-pagebreak = self.at(
+ "horizontal-line-to-pagebreak",
+ default: true,
+ )
+ let children = if utils.is-sequence(body) {
+ body.children
+ } else {
+ (body,)
+ }
+ // convert all sequence to array recursively, and then flatten the array
+ let sequence-to-array(it) = {
+ if utils.is-sequence(it) {
+ it.children.map(sequence-to-array)
+ } else {
+ it
+ }
+ }
+ children = children.map(sequence-to-array).flatten()
+ let call-slide-fn-and-reset(
+ self,
+ already-slide-wrapper: false,
+ slide-fn,
+ current-slide-cont,
+ recaller-map,
+ ) = {
+ let last-heading-label = _get-last-heading-label(self.headings)
+ // Skip handout-only slides when not in handout mode
+ if last-heading-label == "touying:handout" and not self.handout {
+ return (none, recaller-map, (), (), true, false)
+ }
+ let (slide-content, callable) = if already-slide-wrapper {
+ (slide-fn(self), slide-fn)
+ } else {
+ _call-slide-fn(self, slide-fn, current-slide-cont)
+ }
+ if last-heading-label != none {
+ recaller-map.insert(last-heading-label, (
+ content: slide-content,
+ callable: callable,
+ slide-self: self,
+ ))
+ }
+ (slide-content, recaller-map, (), (), true, false)
+ }
+ // The empty content list
+ let empty-content-types = ([], [ ], parbreak(), linebreak())
+ // The headings that we currently have
+ let current-headings = ()
+ // Recaller map
+ let recaller-map = recaller-map
+ // The current slide we are building
+ let slide-parts = ()
+ // The current slide content
+ let slide-content = none
+ // is new start
+ let is-new-start = new-start
+ // start part
+ let start-part = ()
+ // result
+ let output-slides = ()
+ // leading preamble to collect content from before the slide break
+ let leading-preamble = ()
+
+ // Buffer for the last slide-wrapper's callable and self, so that
+ // immediately following speaker-notes can be re-attached to it.
+ // Each element is a (callable, slide-self) pair. Empty means no pending wrapper.
+ let last-wrapper-info = ()
+
+ // Is we have a horizontal line
+ let horizontal-line = false
+ // Iterate over the children
+ for child in children {
+ // Handle horizontal-line
+ // split content when we have a horizontal line
+ if (
+ horizontal-line-to-pagebreak
+ and horizontal-line
+ and child not in ([—], [---], [–], [--], [-])
+ ) {
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ horizontal-line = false
+ absorb-leading-preamble = false
+ }
+ // Clear last-wrapper-info when we encounter anything other than a
+ // speaker-note or whitespace, so that only speaker-notes *immediately*
+ // after a slide-wrapper get attached.
+ if (
+ last-wrapper-info.len() > 0
+ and not utils.is-kind(child, "touying-speaker-note")
+ and child not in ([], [ ], parbreak(), linebreak())
+ ) {
+ while last-wrapper-info.len() > 0 { let _ = last-wrapper-info.pop() }
+ }
+ // Main logic
+ if utils.is-kind(child, "touying-slide-wrapper") {
+ slide-parts = utils.trim(slide-parts)
+ if (
+ slide-parts != ()
+ or _get-slide-fn(self + (headings: current-headings), default: none)
+ != none
+ ) {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ let slide-self = (
+ self + (headings: current-headings, is-first-slide: is-first-slide)
+ )
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ slide-self,
+ already-slide-wrapper: true,
+ child.value.fn,
+ none,
+ recaller-map,
+ )
+ if child.has("label") and child.label != <touying-temporary-mark> {
+ recaller-map.insert(str(child.label), (
+ content: slide-content,
+ callable: child.value.fn,
+ slide-self: slide-self,
+ ))
+ }
+ if slide-content != none { output-slides.push(slide-content) }
+ // Clear and set last-wrapper-info for potential speaker-note attachment
+ while last-wrapper-info.len() > 0 { let _ = last-wrapper-info.pop() }
+ last-wrapper-info.push((child.value.fn, slide-self))
+ absorb-leading-preamble = false
+ } else if (
+ utils.is-kind(child, "touying-speaker-note")
+ and last-wrapper-info.len() > 0
+ and utils.trim(slide-parts) == ()
+ ) {
+ // A speaker-note immediately after a slide-wrapper: re-generate the
+ // previous slide with the note injected into self so it gets processed
+ // inside the slide's subslide-preamble (within the page context).
+ let (original-fn, wrapper-self) = last-wrapper-info.last()
+ let existing-notes = wrapper-self.at(
+ "attached-speaker-notes",
+ default: (),
+ )
+ existing-notes.push(child.value)
+ let new-self = wrapper-self + (attached-speaker-notes: existing-notes)
+ // Replace the last output slide with the new one
+ let _ = output-slides.pop()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ new-self,
+ already-slide-wrapper: true,
+ original-fn,
+ none,
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ // Update last-wrapper-info with the new self
+ while last-wrapper-info.len() > 0 { let _ = last-wrapper-info.pop() }
+ last-wrapper-info.push((original-fn, new-self))
+ } else if utils.is-kind(child, "touying-slide-recaller") {
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ let lbl = child.value.label
+ assert(
+ lbl in recaller-map,
+ message: "label not found in the recaller map for slides",
+ )
+ // recall the slide
+ let recall-entry = recaller-map.at(lbl)
+ let recall-subslide = child.value.at("subslide", default: none)
+ if recall-subslide == none {
+ output-slides.push(recall-entry.content)
+ } else {
+ // Pass the raw subslide spec through to touying-slide, which will
+ // resolve it after computing `repeat` and `waypoints`.
+ let recalled-self = (
+ recall-entry.slide-self
+ + (
+ freeze-slide-counter: true,
+ _recall-subslide: recall-subslide,
+ enable-frozen-states-and-counters: false,
+ )
+ )
+ output-slides.push((recall-entry.callable)(recalled-self))
+ }
+ absorb-leading-preamble = false
+ } else if child in (pagebreak(), pagebreak(weak: true)) {
+ // split content when we have a pagebreak
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ absorb-leading-preamble = false
+ } else if horizontal-line-to-pagebreak and child in ([—], [---]) {
+ horizontal-line = true
+ continue
+ } else if (
+ horizontal-line-to-pagebreak
+ and horizontal-line
+ and child in ([–], [--], [-])
+ ) {
+ continue
+ } else if utils.is-heading(child, depth: slide-level) {
+ let last-heading-depth = _get-last-heading-depth(current-headings)
+ slide-parts = utils.trim(slide-parts)
+ if (
+ _get-slide-fn(
+ self + (headings: current-headings),
+ default: none,
+ )
+ != none
+ or child.depth <= last-heading-depth
+ or slide-parts != ()
+ or (child.depth == 1 and new-section-slide-fn != none)
+ or (child.depth == 2 and new-subsection-slide-fn != none)
+ or (child.depth == 3 and new-subsubsection-slide-fn != none)
+ or (child.depth == 4 and new-subsubsubsection-slide-fn != none)
+ ) {
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ }
+
+ current-headings.push(child)
+ new-start = true
+ absorb-leading-preamble = false
+
+ if (
+ not child.has("label")
+ or str(child.label) not in ("touying:hidden", "touying:skip")
+ ) {
+ // Helper to set last-wrapper-info after a heading-triggered slide.
+ // We extract the wrapper fn by calling the slide function with none
+ // body, which returns a touying-slide-wrapper metadata.
+ let heading-slide-self = (
+ self + (headings: current-headings, is-first-slide: is-first-slide)
+ )
+ if (
+ child.depth == 1
+ and new-section-slide-fn != none
+ and not self.receive-body-for-new-section-slide-fn
+ ) {
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ heading-slide-self,
+ new-section-slide-fn,
+ none,
+ recaller-map,
+ )
+ if slide-content != none {
+ output-slides.push(slide-content)
+ // Track for speaker-note attachment
+ let wrapper = new-section-slide-fn(none)
+ while last-wrapper-info.len() > 0 {
+ let _ = last-wrapper-info.pop()
+ }
+ last-wrapper-info.push((wrapper.value.fn, heading-slide-self))
+ }
+ } else if (
+ child.depth == 2
+ and new-subsection-slide-fn != none
+ and not self.receive-body-for-new-subsection-slide-fn
+ ) {
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ heading-slide-self,
+ new-subsection-slide-fn,
+ none,
+ recaller-map,
+ )
+ if slide-content != none {
+ output-slides.push(slide-content)
+ let wrapper = new-subsection-slide-fn(none)
+ while last-wrapper-info.len() > 0 {
+ let _ = last-wrapper-info.pop()
+ }
+ last-wrapper-info.push((wrapper.value.fn, heading-slide-self))
+ }
+ } else if (
+ child.depth == 3
+ and new-subsubsection-slide-fn != none
+ and not self.receive-body-for-new-subsubsection-slide-fn
+ ) {
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ heading-slide-self,
+ new-subsubsection-slide-fn,
+ none,
+ recaller-map,
+ )
+ if slide-content != none {
+ output-slides.push(slide-content)
+ let wrapper = new-subsubsection-slide-fn(none)
+ while last-wrapper-info.len() > 0 {
+ let _ = last-wrapper-info.pop()
+ }
+ last-wrapper-info.push((wrapper.value.fn, heading-slide-self))
+ }
+ } else if (
+ child.depth == 4
+ and new-subsubsubsection-slide-fn != none
+ and not self.receive-body-for-new-subsubsubsection-slide-fn
+ ) {
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ heading-slide-self,
+ new-subsubsubsection-slide-fn,
+ none,
+ recaller-map,
+ )
+ if slide-content != none {
+ output-slides.push(slide-content)
+ let wrapper = new-subsubsubsection-slide-fn(none)
+ while last-wrapper-info.len() > 0 {
+ let _ = last-wrapper-info.pop()
+ }
+ last-wrapper-info.push((wrapper.value.fn, heading-slide-self))
+ }
+ }
+ }
+ } else if (
+ self.at("auto-offset-for-heading", default: true)
+ and utils.is-heading(child)
+ ) {
+ let fields = child.fields()
+ let lbl = fields.remove("label", default: none)
+ let _ = fields.remove("body", default: none)
+ fields.offset = 0
+ let new-heading = if lbl != none {
+ [#heading(..fields, child.body)#child.label]
+ } else {
+ heading(..fields, child.body)
+ }
+ if new-start {
+ slide-parts.push(new-heading)
+ } else {
+ start-part.push(new-heading)
+ }
+ } else if utils.is-kind(child, "touying-set-config") {
+ // When absorbing leading preamble and no heading seen yet, recurse with
+ // the merged config applied to self and the leading preamble prepended.
+ // Unlike styled nodes, config nodes have no .child — use .value.body.
+ // There is also nothing to reconstruct-styled: the config cascades via self.
+ if absorb-leading-preamble and current-headings == () {
+ let inner-body = if leading-preamble != () {
+ leading-preamble.sum(default: none) + child.value.body
+ } else {
+ child.value.body
+ }
+ leading-preamble = ()
+ output-slides.push(
+ split-content-into-slides(
+ self: utils.merge-dicts(self, child.value.config),
+ recaller-map: recaller-map,
+ new-start: true,
+ is-first-slide: is-first-slide,
+ absorb-leading-preamble: true,
+ inner-body,
+ ),
+ )
+ } else {
+ slide-parts = utils.trim(slide-parts)
+ let is-deferred = child.value.at("defer", default: false)
+ // In probe mode (is-new-start=false), slide-parts starts empty by
+ // design — that alone must not trigger deferred. Only explicit
+ // defer:true, or actual-split mode (is-new-start) with no accumulated
+ // content, goes through the deferred path.
+ if is-deferred or (is-new-start and slide-parts == ()) {
+ // Deferred path: flush current slide (if any body content), then
+ // recursively process the config body as a fresh start with
+ // absorb-leading-preamble so counter-updates/metadata before the
+ // first heading are deferred, not ghost-flushed.
+ // For explicitly-deferred configs (e.g. appendix), also flush when
+ // current-headings is non-empty so the heading becomes a regular
+ // slide instead of leaking into the appendix body.
+ // For non-deferred configs that fire because slide-parts=(), do NOT
+ // flush the heading — it belongs to the config body and goes into
+ // pending-headings below.
+ if slide-parts != () or (is-deferred and current-headings != ()) {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ // Forward any pending headings and any accumulated leading-preamble
+ // into the recursive body so counter-updates and state changes that
+ // arrived before the first heading are not silently dropped.
+ // (When slide-parts was non-empty the flush above already cleared
+ // leading-preamble; when slide-parts was empty it was not cleared.)
+ let pending-headings = current-headings
+ current-headings = ()
+ slide-parts = ()
+ let recursive-body = {
+ let parts = ()
+ if leading-preamble != () {
+ parts.push(leading-preamble.sum(default: none))
+ }
+ if pending-headings != () {
+ parts.push(pending-headings.sum(default: none))
+ }
+ parts.push(child.value.body)
+ parts.sum(default: none)
+ }
+ leading-preamble = ()
+ output-slides.push(
+ split-content-into-slides(
+ self: utils.merge-dicts(self, child.value.config),
+ recaller-map: recaller-map,
+ new-start: true,
+ absorb-leading-preamble: true,
+ recursive-body,
+ ),
+ )
+ } else {
+ // Immediate path: slide-parts is non-empty and defer is false.
+ // Handle like styled content — split the config body, recombine
+ // pre-break content with the current slide, and emit the rest.
+ // This keeps mid-slide config changes (e.g. cover method) on the
+ // current slide so that #pause / #meanwhile still work.
+ let merged-self = utils.merge-dicts(self, child.value.config)
+ // Do NOT forward pending headings — they belong to the current
+ // slide and will be used when flushing. Only use the raw config
+ // body for the probe so that start-part captures pre-break content.
+ // Probe the body for slide breaks.
+ let (
+ inner-start-part,
+ slide-content-part,
+ ) = split-content-into-slides(
+ self: merged-self,
+ recaller-map: recaller-map,
+ new-start: false,
+ child.value.body,
+ )
+ if slide-content-part != none {
+ // The config body contains slide-breaking content.
+ // Recombine pre-break content (inner-start-part) with the current
+ // slide, flush, then emit the remaining slides.
+ // In probe mode (new-start=false) push to start-part so the
+ // caller can collect it; in actual-split push to slide-parts.
+ // no is-first-slide branch needed here, unlike style nodes. is-first-slide already there from outside.
+ if inner-start-part != none {
+ if new-start {
+ slide-parts.push(inner-start-part)
+ } else {
+ start-part.push(inner-start-part)
+ }
+ }
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ merged-self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ current-headings = ()
+ slide-parts = ()
+ output-slides.push(slide-content-part)
+ } else {
+ // No slide breaks in the config body — all content stays on the
+ // current slide. Flush immediately with the merged config since
+ // the config body wraps all remaining content (show-rule
+ // semantics) and the for-loop has no more children after this.
+ // In probe mode (new-start=false) push to start-part; in
+ // actual-split push to slide-parts.
+ if inner-start-part != none {
+ if new-start {
+ slide-parts.push(inner-start-part)
+ } else {
+ start-part.push(inner-start-part)
+ }
+ }
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ merged-self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ current-headings = ()
+ slide-parts = ()
+ }
+ }
+ }
+ } else if utils.is-styled(child) {
+ // When absorbing leading preamble and no heading seen yet, recurse into
+ // the styled node with absorb-leading-preamble: true. The set/show rules
+ // will propagate via reconstruct-styled on the output.
+ if absorb-leading-preamble and current-headings == () {
+ let inner-body = if leading-preamble != () {
+ leading-preamble.sum(default: none) + child.child
+ } else {
+ child.child
+ }
+ leading-preamble = ()
+ let inner-result = split-content-into-slides(
+ self: self,
+ recaller-map: recaller-map,
+ new-start: true,
+ is-first-slide: is-first-slide,
+ absorb-leading-preamble: true,
+ inner-body,
+ )
+ output-slides.push(utils.reconstruct-styled(child, inner-result))
+ } else {
+ // Split the content into slides recursively for styled content
+ let (inner-start-part, slide-content-part) = split-content-into-slides(
+ self: self,
+ recaller-map: recaller-map,
+ new-start: false,
+ child.child,
+ )
+ if slide-content-part != none {
+ // The styled node contains slide-breaking content (e.g., headings that
+ // trigger new slides).
+ if is-first-slide {
+ // On the first slide, calling with new-start: false causes
+ // content after headings to land in start-part instead of slide-parts,
+ // resulting in slides with missing bodies. Re-call with new-start: true
+ // to build slides correctly, and flush any accumulated content beforehand.
+ // There is no previous slide to reconcile inner-start-part onto, so we
+ // do NOT attempt to reconcile it here.
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ output-slides.push(
+ utils.reconstruct-styled(
+ child,
+ split-content-into-slides(
+ self: self,
+ recaller-map: recaller-map,
+ new-start: true,
+ is-first-slide: is-first-slide,
+ child.child,
+ ),
+ ),
+ )
+ } else {
+ // Add the pre-heading content to the current slide, then flush and
+ // emit the new slides directly instead of using _delayed-wrapper,
+ // which would hide them when show-delayed-wrapper is false.
+ if inner-start-part != none {
+ let styled-start = utils.reconstruct-styled(
+ child,
+ inner-start-part,
+ )
+ if new-start {
+ slide-parts.push(styled-start)
+ } else {
+ start-part.push(styled-start)
+ }
+ }
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+ // Add new slides, wrapped in the same styled node so that the
+ // show/set rules cascade to subsequent slides (matching Typst semantics)
+ output-slides.push(utils.reconstruct-styled(
+ child,
+ slide-content-part,
+ ))
+ }
+ } else {
+ // No slide-breaking content inside; use the original delayed-wrapper
+ // approach so that subslide animations work correctly within the styled scope
+ let styled-child = {
+ if inner-start-part != none {
+ utils.reconstruct-styled(child, inner-start-part)
+ }
+ _delayed-wrapper(utils.reconstruct-styled(child, none))
+ }
+ if new-start {
+ slide-parts.push(styled-child)
+ } else {
+ start-part.push(styled-child)
+ }
+ }
+ }
+ } else {
+ if absorb-leading-preamble and current-headings == () {
+ // Before any heading is seen, accumulate as preamble to inject into
+ // the first real slide's content — avoids ghost slides from counter-updates
+ // and metadata injected by touying-set-config bodies.
+ leading-preamble.push(child)
+ } else if new-start {
+ slide-parts.push(child)
+ } else {
+ start-part.push(child)
+ }
+ }
+ }
+
+ // Handle the last slide
+ slide-parts = utils.trim(slide-parts)
+ if slide-parts != () or current-headings != () {
+ let flush-self = (
+ self
+ + (
+ headings: current-headings,
+ is-first-slide: is-first-slide,
+ leading-preamble: leading-preamble,
+ )
+ )
+ leading-preamble = ()
+ (
+ slide-content,
+ recaller-map,
+ current-headings,
+ slide-parts,
+ new-start,
+ is-first-slide,
+ ) = call-slide-fn-and-reset(
+ flush-self,
+ slide-fn,
+ slide-parts.sum(default: none),
+ recaller-map,
+ )
+ if slide-content != none { output-slides.push(slide-content) }
+ }
+
+ if is-new-start {
+ return output-slides.sum(default: none)
+ } else {
+ return (start-part.sum(default: none), output-slides.sum(default: none))
+ }
+}
+
+/// ------------------------------------------------
+/// Slide
+/// ------------------------------------------------
+
+/// Wrapper for a function to make it can receive `self` as an argument.
+/// It is useful when you want to use `self` to get current subslide index, like `uncover` and `only` functions.
+///
+/// Example: `#let alternatives = touying-fn-wrapper.with(utils.alternatives)`
+///
+/// - fn (function): The function that will be called like `(self: none, ..args) => { .. }`.
+///
+/// - last-subslide (int): The max subslide count for the slide. Used by functions like `uncover`, `only`, and `alternatives-match` to determine the total number of subslides needed.
+///
+/// - repetitions (function): The repetitions for the function. It is useful for functions like `alternatives` with `start: auto`.
+///
+/// It accepts a `(repetitions, args)` and should return a (nextrepetitions, extra-args).
+///
+/// -> content
+#let touying-fn-wrapper(
+ fn,
+ last-subslide: none,
+ repetitions: none,
+ ..args,
+) = [#metadata((
+ kind: "touying-fn-wrapper",
+ fn: fn,
+ args: args,
+ last-subslide: last-subslide,
+ repetitions: repetitions,
+))<touying-temporary-mark>]
+
+// A raw version of `touying-fn-wrapper` that does not support `last-subslide` and `repetitions`.
+// It is for wrapping functions that should be affected by the repetition counter surrounding them.
+// e.g. `utils.alert`
+//
+// - fn (function): The function that will be called like `(self: none, ..args) => { .. }`.
+//
+// - args: The arguments to pass to the function. E.g. content
+//
+// -> content
+#let touying-fn-wrapper-raw(
+ fn,
+ ..args,
+) = [#metadata((
+ kind: "touying-fn-wrapper-raw",
+ fn: fn,
+ args: args,
+))<touying-temporary-mark>]
+/// Wrapper for a slide function to make it can receive `self` as an argument.
+///
+/// Notice: This function is necessary for the slide function to work in Touying.
+///
+/// Example:
+///
+/// ```typst
+/// #let slide(..args) = touying-slide-wrapper(self => {
+/// touying-slide(self: self, ..args)
+/// })
+/// ```
+///
+/// - fn (function): The function that will be called with an argument `self` like `self => { .. }`.
+///
+/// -> content
+#let touying-slide-wrapper(fn) = [#metadata((
+ kind: "touying-slide-wrapper",
+ fn: fn,
+))<touying-temporary-mark>]
+
+
+/// Jump to a subslide position, either relatively or absolutely.
+///
+/// This is the unified core for both `#pause` and `#meanwhile`.
+///
+/// - When `relative: true` (relative mode): advances the subslide counter by `n`.
+/// Positive `n` moves forward; negative `n` moves backward.
+/// `n` must be a non-zero integer (zero would be a no-op with no visible effect).
+/// `#pause` is equivalent to `#jump(1, relative: true)`.
+///
+/// - When `relative: false` (absolute mode, default): reveals all currently hidden
+/// content and jumps to absolute subslide `n`.
+/// `#meanwhile` is equivalent to `#jump(1)`.
+///
+/// Example:
+///
+/// ```typst
+/// A #jump(1, relative: true) B // same as A #pause B
+/// A #jump(2, relative: true) C // skip an extra subslide before C
+/// A #pause B #jump(1) C // C is always visible (same as #meanwhile)
+/// A #pause B #jump(3) D // D visible from subslide 3 onward
+/// // A #pause B #pause C — normally C appears at subslide 3;
+/// // adding #jump(-1, relative: true) before D makes D appear at subslide 2 (same as B):
+/// A #pause B #pause C #jump(-1, relative: true) D
+/// ```
+///
+/// - n (int): When `relative: true`, the number of subslides to advance (non-zero integer).
+/// When `relative: false`, the absolute target subslide number (positive integer >= 1).
+///
+/// - relative (bool): If `true`, `n` is a relative offset from the current subslide counter.
+/// If `false` (default), `n` is an absolute target subslide number.
+///
+/// -> content
+#let jump(n, relative: false) = {
+ if relative {
+ assert(
+ type(n) == int and n != 0,
+ message: "jump: n must be a non-zero integer when relative: true, got "
+ + repr(n),
+ )
+ } else {
+ assert(
+ type(n) == int and n >= 1,
+ message: "jump: n must be a positive integer when relative: false, got "
+ + repr(n),
+ )
+ }
+ [#metadata((
+ kind: "touying-jump/pause/meanwhile",
+ n: n,
+ relative: relative,
+ ))<touying-temporary-mark>]
+}
+
+
+/// Reveal the next subslide. Inserts a subslide break: content after `#pause` appears one subslide later. Equivalent to `#jump(1, relative: true)`.
+///
+/// -> content
+#let pause = jump(1, relative: true)
+
+
+/// Reset the subslide counter back to 1, allowing content after `#meanwhile` to appear simultaneously with content from subslide 1. Equivalent to `#jump(1)`.
+///
+/// -> content
+#let meanwhile = jump(1)
+
+/// ------------------------------------------------
+/// Waypoints
+/// (by Zral0kh)
+/// ------------------------------------------------
+///
+/// Declare a named waypoint in the slide's subslide sequence.
+///
+/// A waypoint names a set of subslide positions so that it can be referred to
+/// by label in `uncover`, `only`, `effect`, `alternatives`, and other animation
+/// functions. This lets you avoid counting subslide numbers manually.
+///
+/// By default, a waypoint call also acts as a `#pause` (advancing to the next subslide).
+/// Set `advance: false` to mark the current position without advancing.
+///
+/// A waypoint covers all subslides from its declaration until the next waypoint
+/// is declared (or the end of the slide).
+///
+/// You can also set the starting subslide of a waypoint with the `start` argument, which allows for more flexible control, independent of how your content may be structured. Circular waypoint dependencies will panic!
+/// Thus passing `start` will basically act like a `jump(start)` at the position of the waypoint.
+///
+/// Note that your labels need to be slide-unique. They need not be globally
+/// unique, but must be unique within a single slide.
+///
+/// You can use hierarchical labels with `:` separators (e.g. `<part:intro>`).
+/// When referencing `<part>`, all waypoints starting with `part:` are combined.
+///
+/// Example:
+///
+/// ```typst
+/// Some content
+/// #waypoint(<reveal>)
+/// #uncover(<reveal>)[Revealed content]
+/// #waypoint(<highlight>)
+/// #effect(text.with(fill: red), <highlight>)[Highlighted]
+/// ```
+///
+/// - lbl (label): The label for this waypoint.
+///
+/// - advance (bool): If `true` (default), acts as a `#pause` before marking.
+/// If `false`, marks the current subslide position without advancing.
+///
+/// - start (auto | int | label): The starting subslide for this waypoint. By default the location in the content and depends on `advance`. If set ignores `advance` and allows to specify a subslide index or a waypoint.
+///
+/// -> content
+#let waypoint(lbl, advance: true, start: auto) = {
+ assert(
+ type(lbl) == label,
+ message: "waypoint: expected a label, got " + str(type(lbl)),
+ )
+ let start-value = if type(start) == label {
+ str(start)
+ } else {
+ start
+ }
+ [#metadata((
+ kind: "touying-waypoint",
+ label: str(lbl),
+ advance: advance,
+ start: start-value,
+ ))<touying-temporary-mark>]
+}
+
+#let waypoint-kinds = (
+ "waypoint",
+ "implicit-waypoint",
+ "waypoint-first",
+ "waypoint-last",
+ "waypoint-from",
+ "waypoint-until",
+ "waypoint-prev",
+ "waypoint-next",
+ "waypoint-not",
+).map(el => "touying-" + el)
+
+
+/// Get the first subslide number of a waypoint.
+///
+/// Returns a marker dictionary that will be resolved automatically when used as
+/// a `visible-subslides` argument in `uncover`, `only`, `effect`, etc.
+///
+/// Example: `#only(get-first(<my-label>))[content]`
+///
+/// - lbl (label): The waypoint label.
+///
+/// -> dictionary
+#let get-first(lbl) = {
+ assert(
+ type(lbl) == label,
+ message: "get-first: expected a label, got " + str(type(lbl)),
+ )
+ (
+ kind: "touying-waypoint-first",
+ label: str(lbl),
+ )
+}
+
+
+/// Get the last subslide number of a waypoint.
+///
+/// Returns a marker dictionary that will be resolved automatically when used as
+/// a `visible-subslides` argument in `uncover`, `only`, `effect`, etc.
+///
+/// Example: `#only(get-last(<my-label>))[content]`
+///
+/// - lbl (label): The waypoint label.
+///
+/// -> dictionary
+#let get-last(lbl) = {
+ assert(
+ type(lbl) == label,
+ message: "get-last: expected a label, got " + str(type(lbl)),
+ )
+ (
+ kind: "touying-waypoint-last",
+ label: str(lbl),
+ )
+}
+
+
+/// Create a "from-wp" range starting at a waypoint (inclusive to end of slide).
+///
+/// Returns a range marker visible from the waypoint's first subslide onward.
+/// Does *not* create a waypoint — the referenced label must be defined
+/// elsewhere (via `#waypoint()` or an implicit waypoint in `#uncover`, etc.).
+///
+/// Can be composed with `prev-wp` / `next-wp`:
+/// `from-wp(next-wp(<my-label>))` starts at the waypoint after `<my-label>`.
+///
+/// Combine with `until-wp` in an array for bounded ranges:
+/// `(from-wp(<a>), until-wp(<b>))` — visible from `<a>` to just before `<b>`.
+///
+/// - wp (label | dictionary): A waypoint label or a shifted reference
+/// (e.g. `next-wp(<label>)`).
+///
+/// -> dictionary
+#let from-wp(wp) = {
+ assert(
+ type(wp) in (label, str, dictionary),
+ message: "from-wp: expected a label or waypoint marker, got "
+ + str(type(wp)),
+ )
+ (
+ kind: "touying-waypoint-from",
+ inner: if type(wp) == label {
+ str(wp)
+ } else {
+ wp
+ },
+ )
+}
+
+
+/// Create an "until-wp" range ending just before a waypoint (exclusive).
+///
+/// Returns a range marker visible from subslide 1 up to (but not including)
+/// the waypoint's first subslide. Does *not* create a waypoint.
+///
+/// Can be composed with `prev-wp` / `next-wp`:
+/// `until-wp(prev-wp(<my-label>))` ends before the waypoint preceding `<my-label>`.
+///
+/// Combine with `from-wp` in an array for bounded ranges:
+/// `(from-wp(<a>), until-wp(<b>))` — visible from `<a>` to just before `<b>`.
+///
+/// - wp (label | dictionary): A waypoint label or a shifted reference.
+///
+/// -> dictionary
+#let until-wp(wp) = {
+ assert(
+ type(wp) in (label, str, dictionary),
+ message: "until-wp: expected a label or waypoint marker, got "
+ + str(type(wp)),
+ )
+ (
+ kind: "touying-waypoint-until",
+ inner: if type(wp) == label {
+ str(wp)
+ } else {
+ wp
+ },
+ )
+}
+
+
+/// Shift a waypoint reference to a previous waypoint in subslide order.
+///
+/// Given a waypoint label, returns a reference to the waypoint `amount` steps
+/// before it. `prev-wp(<c>, amount: 2)` is equivalent to
+/// `prev-wp(prev-wp(<c>))`.
+///
+/// When applied to a `from-wp` or `until-wp` marker, the shift is pushed inward:
+/// `prev-wp(from-wp(<c>))` becomes `from-wp(prev-wp(<c>))`.
+///
+/// - wp (label | dictionary): A waypoint label or marker to shift.
+///
+/// - amount (int): How many waypoints to step back. Default is `1`.
+///
+/// -> dictionary
+#let prev-wp(wp, amount: 1) = {
+ assert(
+ type(wp) in (label, str, dictionary),
+ message: "prev-wp: expected a label or waypoint marker, got "
+ + str(type(wp)),
+ )
+ if type(wp) == label {
+ (kind: "touying-waypoint-prev", inner: str(wp), amount: amount)
+ } else if type(wp) == dictionary {
+ let kind = wp.at("kind", default: none)
+ if kind in ("touying-waypoint-from", "touying-waypoint-until") {
+ // Push shift inward: prev-wp(from-wp(<x>)) → from-wp(prev-wp(<x>))
+ (..wp, inner: prev-wp(wp.inner, amount: amount))
+ } else {
+ (kind: "touying-waypoint-prev", inner: wp, amount: amount)
+ }
+ } else {
+ (kind: "touying-waypoint-prev", inner: wp, amount: amount)
+ }
+}
+
+
+/// Shift a waypoint reference to a later waypoint in subslide order.
+///
+/// Given a waypoint label, returns a reference to the waypoint `amount` steps
+/// after it. `next-wp(<a>, amount: 2)` is equivalent to
+/// `next-wp(next-wp(<a>))`.
+///
+/// When applied to a `from` or `until` marker, the shift is pushed inward:
+/// `next-wp(until-wp(<a>))` becomes `until-wp(next-wp(<a>))`.
+///
+/// - wp (label | dictionary): A waypoint label or marker to shift.
+///
+/// - amount (int): How many waypoints to step forward. Default is `1`.
+///
+/// -> dictionary
+#let next-wp(wp, amount: 1) = {
+ assert(
+ type(wp) in (label, str, dictionary),
+ message: "next-wp: expected a label or waypoint marker, got "
+ + str(type(wp)),
+ )
+ if type(wp) == label {
+ (kind: "touying-waypoint-next", inner: str(wp), amount: amount)
+ } else if type(wp) == dictionary {
+ let kind = wp.at("kind", default: none)
+ if kind in ("touying-waypoint-from", "touying-waypoint-until") {
+ // Push shift inward: next-wp(until-wp(<x>)) → until-wp(next-wp(<x>))
+ (..wp, inner: next-wp(wp.inner, amount: amount))
+ } else {
+ (kind: "touying-waypoint-next", inner: wp, amount: amount)
+ }
+ } else {
+ (kind: "touying-waypoint-next", inner: wp, amount: amount)
+ }
+}
+
+
+/// Negate a waypoint marker — visible on all subslides *except* the referenced ones.
+///
+/// Works with bare labels, `from-wp`, `until-wp`, `prev-wp`, `next-wp`,
+/// `get-first`, `get-last`, or any other waypoint marker.
+///
+/// Like the `"!"` prefix for strings, `not-wp` cannot introduce new subslides —
+/// it only masks existing ones.
+///
+/// Example: `#only(not-wp(<my-label>))[hidden during my-label]`
+///
+/// - wp (label | dictionary): A waypoint label or marker to negate.
+///
+/// -> dictionary
+#let not-wp(wp) = {
+ assert(
+ type(wp) in (label, str, dictionary),
+ message: "not-wp: expected a label or waypoint marker, got "
+ + str(type(wp)),
+ )
+ (
+ kind: "touying-waypoint-not",
+ inner: if type(wp) == label {
+ str(wp)
+ } else {
+ wp
+ },
+ )
+}
+
+
+// Helper: check if a subslide spec string contains "h" (here marker)
+// that needs deferred resolution to the current repetitions counter.
+#let _has-here-marker(visible-subslides) = (
+ type(visible-subslides) == str and visible-subslides.contains("h")
+)
+
+// Helper: create a last-subslide callback that resolves "h" in a string
+// to the current repetitions counter at placement time.
+#let _here-last-subslide(visible-subslides) = {
+ repetitions => {
+ let resolved = visible-subslides.replace("h", str(repetitions))
+ (utils.last-required-subslide(resolved), (resolved-subslides: resolved))
+ }
+}
+
+
+/// Take effect in some subslides.
+///
+/// Example: `#effect(text.with(fill: red), "2-")[Something]` will display `[Something]` if the current slide is 2 or later.
+///
+/// You can also add an abbreviation by using `#let effect-red = effect.with(text.with(fill: red))` for your own effects.
+///
+/// - fn (function): The function that will be called in the subslide.
+/// Or you can use a method function like `(self: none) => { .. }`.
+///
+/// - visible-subslides (int, array, str, label, dictionary): Specifies when content is visible.
+///
+/// Supported formats:
+///
+/// - A single integer, e.g. `3` — only subslide 3.
+/// - An array, e.g. `(1, 2, 4)` — equivalent to `"1, 2, 4"`.
+/// - A string with ranges, e.g. `"-2, 4, 6-8, 10-"` — subslides 1, 2, 4, 6, 7, 8, 10, and all after 10.
+/// - A label, e.g. `<my-waypoint>` — creates an implicit waypoint and shows from there onward.
+/// - A waypoint marker, e.g. `from-wp(<label>)`, `until-wp(<label>)`, `get-first(<label>)`, etc.
+///
+/// - cont (content): The content to display when visible.
+///
+/// - is-method (bool): Whether the function is a method function. Default is `false`.
+#let effect(fn, visible-subslides, cont, is-method: false) = {
+ if visible-subslides == auto {
+ // auto: resolve to current repetitions at placement time, no advance.
+ touying-fn-wrapper(
+ utils.effect,
+ last-subslide: repetitions => (
+ repetitions,
+ (resolved-subslides: repetitions),
+ ),
+ fn,
+ auto,
+ is-method: is-method,
+ cont,
+ )
+ } else if _has-here-marker(visible-subslides) {
+ // "h" marker: deferred resolution of "h" to current repetitions.
+ touying-fn-wrapper(
+ utils.effect,
+ last-subslide: _here-last-subslide(visible-subslides),
+ fn,
+ visible-subslides,
+ is-method: is-method,
+ cont,
+ )
+ } else {
+ if type(visible-subslides) == label {
+ [#metadata((
+ kind: "touying-implicit-waypoint",
+ label: str(visible-subslides),
+ ))<touying-temporary-mark>]
+ }
+ touying-fn-wrapper(
+ utils.effect,
+ last-subslide: utils.last-required-subslide(visible-subslides),
+ fn,
+ visible-subslides,
+ is-method: is-method,
+ cont,
+ )
+ }
+}
+
+
+/// Uncover content in some subslides. Reserved space when hidden (like `#hide()`).
+///
+/// #example(
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+/// = Slide
+///
+/// #uncover("2-")[Only visible from subslide 2]
+/// )
+///
+/// - visible-subslides (int, array, str, label, dictionary): Specifies when content is visible.
+///
+/// Supported formats:
+///
+/// - A single integer, e.g. `3` — only subslide 3.
+/// - An array, e.g. `(1, 2, 4)` — equivalent to `"1, 2, 4"`.
+/// - A string with ranges, e.g. `"-2, 4, 6-8, 10-"` — subslides 1, 2, 4, 6, 7, 8, 10, and all after 10.
+/// - A label, e.g. `<my-waypoint>` — creates an implicit waypoint and shows from there onward.
+/// - A waypoint marker, e.g. `from-wp(<label>)`, `until-wp(<label>)`, `get-first(<label>)`, etc.
+///
+/// - uncover-cont (content): The content to display when visible.
+///
+/// - cover-fn (function, auto): An optional cover function to use instead of the default cover method from the theme. Useful when using `uncover` inside external package integrations (e.g. `fletcher.hide` for fletcher diagrams).
+///
+/// -> content
+#let uncover(visible-subslides, uncover-cont, cover-fn: auto) = {
+ if visible-subslides == auto {
+ // auto: resolve to current repetitions at placement time, no advance.
+ touying-fn-wrapper(
+ utils.uncover,
+ last-subslide: repetitions => (
+ repetitions,
+ (resolved-subslides: repetitions),
+ ),
+ auto,
+ uncover-cont,
+ cover-fn: cover-fn,
+ )
+ } else if _has-here-marker(visible-subslides) {
+ // "h" marker: deferred resolution of "h" to current repetitions.
+ touying-fn-wrapper(
+ utils.uncover,
+ last-subslide: _here-last-subslide(visible-subslides),
+ visible-subslides,
+ uncover-cont,
+ cover-fn: cover-fn,
+ )
+ } else {
+ if type(visible-subslides) == label {
+ [#metadata((
+ kind: "touying-implicit-waypoint",
+ label: str(visible-subslides),
+ ))<touying-temporary-mark>]
+ }
+ touying-fn-wrapper(
+ utils.uncover,
+ last-subslide: utils.last-required-subslide(visible-subslides),
+ visible-subslides,
+ uncover-cont,
+ cover-fn: cover-fn,
+ )
+ }
+}
+
+
+/// Display content in some subslides only. No space is reserved when hidden.
+///
+/// #example(
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+/// = Slide
+///
+/// #only("2")[Only on subslide 2]
+/// )
+///
+/// - visible-subslides (int, array, str, label, dictionary): Specifies when content is visible.
+///
+/// Supported formats:
+///
+/// - A single integer, e.g. `3` — only subslide 3.
+/// - An array, e.g. `(1, 2, 4)` — equivalent to `"1, 2, 4"`.
+/// - A string with ranges, e.g. `"-2, 4, 6-8, 10-"` — subslides 1, 2, 4, 6, 7, 8, 10, and all after 10.
+/// - A label, e.g. `<my-waypoint>` — creates an implicit waypoint and shows from there onward.
+/// - A waypoint marker, e.g. `from-wp(<label>)`, `until-wp(<label>)`, `get-first(<label>)`, etc.
+///
+/// - only-cont (content): The content to display when visible.
+///
+/// -> content
+#let only(visible-subslides, only-cont) = {
+ if visible-subslides == auto {
+ // auto: resolve to current repetitions at placement time, no advance.
+ touying-fn-wrapper(
+ utils.only,
+ last-subslide: repetitions => (
+ repetitions,
+ (resolved-subslides: repetitions),
+ ),
+ auto,
+ only-cont,
+ )
+ } else if _has-here-marker(visible-subslides) {
+ // "h" marker: deferred resolution of "h" to current repetitions.
+ touying-fn-wrapper(
+ utils.only,
+ last-subslide: _here-last-subslide(visible-subslides),
+ visible-subslides,
+ only-cont,
+ )
+ } else {
+ if type(visible-subslides) == label {
+ [#metadata((
+ kind: "touying-implicit-waypoint",
+ label: str(visible-subslides),
+ ))<touying-temporary-mark>]
+ }
+ touying-fn-wrapper(
+ utils.only,
+ last-subslide: utils.last-required-subslide(visible-subslides),
+ visible-subslides,
+ only-cont,
+ )
+ }
+}
+
+
+/// Display content only in handout mode.
+/// Don't reserve space when hidden, content is completely not existing there.
+///
+/// Example:
+///
+/// ```typst
+/// #handout-only[This content is only visible in handout mode.]
+/// ```
+///
+/// - cont (content): The content to display in handout mode.
+///
+/// -> content
+#let handout-only(cont) = {
+ touying-fn-wrapper(
+ utils.handout-only,
+ cont,
+ )
+}
+
+
+/// `#alternatives` has a couple of "cousins" that might be more convenient in some situations. The first one is `#alternatives-match` that has a name inspired by match-statements in many functional programming languages. The idea is that you give it a dictionary mapping from subslides to content:
+///
+/// Example:
+///
+/// ```typst
+/// #alternatives-match((
+/// "1, 3-5": [this text has the majority],
+/// "2, 6": [this is shown less often]
+/// ))
+/// ```
+///
+/// - subslides-contents (dictionary): A dictionary mapping from subslides to content.
+///
+/// - position (alignment): The alignment of alternatives within the reserved space. Default is `bottom + left`.
+///
+/// - stretch (bool): Whether to stretch all alternatives to the maximum width and height. Default is `false`.
+///
+/// Important: If you use a zero-length content like a context expression, you should set `stretch: false`.
+///
+/// -> content
+#let alternatives-match(
+ subslides-contents,
+ position: bottom + left,
+ stretch: false,
+) = {
+ // Validate: alternatives-match doesn't support waypoints, only numeric subslide specs
+ let keys = if type(subslides-contents) == dictionary {
+ subslides-contents.keys()
+ } else {
+ subslides-contents.map(kv => kv.at(0))
+ }
+ for key in keys {
+ if type(key) == label {
+ panic(
+ "alternatives-match: waypoint labels are not supported. Use alternatives() with the at: parameter instead.",
+ )
+ }
+ if (
+ type(key) == dictionary
+ and "kind" in key
+ and str(key.kind) in waypoint-kinds
+ ) {
+ panic(
+ "alternatives-match: waypoint markers are not supported. Use alternatives() with the at: parameter instead.",
+ )
+ }
+ }
+ touying-fn-wrapper(
+ utils.alternatives-match,
+ last-subslide: if type(subslides-contents) == dictionary {
+ calc.max(..subslides-contents
+ .pairs()
+ .map(kv => utils.last-required-subslide(kv.at(0))))
+ } else {
+ calc.max(..subslides-contents.map(kv => utils.last-required-subslide(
+ kv.at(0),
+ )))
+ },
+ subslides-contents,
+ position: position,
+ stretch: stretch,
+ )
+}
+
+
+/// `#alternatives` is able to show contents sequentially in subslides.
+///
+/// Example: `#alternatives[Ann][Bob][Christopher]` will show "Ann" in the first subslide, "Bob" in the second subslide, and "Christopher" in the third subslide.
+///
+/// You can also use waypoint labels via the `at` parameter:
+///
+/// ```typst
+/// #alternatives(at: (<first>, <second>))[Content A][Content B]
+/// ```
+///
+/// - start (int): The starting subslide number. Default is `auto`.
+///
+/// - repeat-last (bool): Whether the last alternative should persist on all remaining subslides. Default is `true`.
+///
+/// - position (alignment): The alignment of alternatives within the reserved space. Default is `bottom + left`.
+///
+/// - stretch (bool): Whether to stretch all alternatives to the maximum width and height. Default is `false`.
+///
+/// Important: If you use a zero-length content like a context expression, you should set `stretch: false`.
+///
+/// - at (none | array): An array of waypoint labels (or waypoint markers like `get-first(<label>)`) or subslide specs, one per body.
+/// When provided, each body is mapped to the corresponding waypoint range.
+/// This is an alternative to the sequential `start`-based numbering.
+///
+/// -> content
+#let alternatives(
+ start: auto,
+ repeat-last: true,
+ position: bottom + left,
+ stretch: false,
+ at: none,
+ ..args,
+) = {
+ if at != none {
+ // Waypoint-based alternatives: map each label to its corresponding body
+ let bodies = args.pos()
+ assert(
+ at.len() == bodies.len(),
+ message: "alternatives: `at` array length ("
+ + str(at.len())
+ + ") must match number of bodies ("
+ + str(bodies.len())
+ + ")",
+ )
+ let subslides = at
+ if repeat-last and subslides.len() > 0 {
+ // Replace last entry with a from-wp() marker so it shows from that
+ // waypoint onward (not just within its bounded range).
+ let last-entry = subslides.last()
+ subslides.at(-1) = if type(last-entry) == label {
+ from-wp(last-entry)
+ } else {
+ last-entry
+ }
+ }
+ let subslides-contents = subslides.zip(bodies)
+ touying-fn-wrapper(
+ utils.alternatives-match,
+ last-subslide: calc.max(
+ ..subslides.map(s => utils.last-required-subslide(s)),
+ ),
+ subslides-contents,
+ position: position,
+ stretch: stretch,
+ )
+ } else {
+ let extra = if start == auto {
+ (
+ last-subslide: repetitions => (
+ repetitions + args.pos().len() - 1,
+ (start: repetitions),
+ ),
+ )
+ } else {
+ (
+ last-subslide: start + args.pos().len() - 1,
+ )
+ }
+ touying-fn-wrapper(
+ utils.alternatives,
+ start: start,
+ repeat-last: repeat-last,
+ position: position,
+ stretch: stretch,
+ ..extra,
+ ..args,
+ )
+ }
+}
+
+
+/// You can have very fine-grained control over the content depending on the current subslide by using `#alternatives-fn`. It accepts a function (hence the name) that maps the current subslide index to some content.
+///
+/// Example: `#alternatives-fn(start: 2, count: 7, subslide => { numbering("(i)", subslide) })`
+///
+/// - start (int): The starting subslide number. Default is `1`.
+///
+/// - end (none, int): The ending subslide number. Default is `none`.
+///
+/// - count (none, int): The number of subslides. Default is `none`.
+///
+/// - position (alignment): The alignment of alternatives within the reserved space. Default is `bottom + left`.
+///
+/// - stretch (bool): Whether to stretch all alternatives to the maximum width and height. Default is `false`.
+///
+/// Important: If you use a zero-length content like a context expression, you should set `stretch: false`.
+///
+/// -> content
+#let alternatives-fn(
+ start: 1,
+ end: none,
+ count: none,
+ position: bottom + left,
+ stretch: false,
+ ..kwargs,
+ fn,
+) = {
+ // Validate integer parameters
+ assert(
+ type(start) == int,
+ message: "alternatives-fn: start must be an integer, got "
+ + str(type(start)),
+ )
+ if end != none {
+ assert(
+ type(end) == int,
+ message: "alternatives-fn: end must be an integer or none, got "
+ + str(type(end)),
+ )
+ }
+ if count != none {
+ assert(
+ type(count) == int,
+ message: "alternatives-fn: count must be an integer or none, got "
+ + str(type(count)),
+ )
+ }
+ let end = if end == none {
+ if count == none {
+ panic("You must specify either end or count.")
+ } else {
+ start + count
+ }
+ } else {
+ end
+ }
+ touying-fn-wrapper(
+ utils.alternatives-fn,
+ last-subslide: end,
+ start: start,
+ end: end,
+ count: count,
+ position: position,
+ stretch: stretch,
+ ..kwargs,
+ fn,
+ )
+}
+
+
+/// You can use this function if you want to have one piece of content that changes only slightly depending of what "case" of subslides you are in.
+///
+/// Example:
+///
+/// ```typst
+/// #alternatives-cases(("1, 3", "2"), case => [
+/// #set text(fill: teal) if case == 1
+/// Some text
+/// ])
+/// ```
+///
+/// - cases (array): An array of strings that specify the subslides for each case.
+///
+/// - fn (function): A function that maps the case to content. The argument `case` is the index of the cases array you input.
+///
+/// - position (alignment): The alignment of alternatives within the reserved space. Default is `bottom + left`.
+///
+/// - stretch (bool): Whether to stretch all alternatives to the maximum width and height. Default is `false`.
+///
+/// Important: If you use a zero-length content like a context expression, you should set `stretch: false`.
+///
+/// -> content
+#let alternatives-cases(
+ cases,
+ fn,
+ position: bottom + left,
+ stretch: false,
+ ..kwargs,
+) = {
+ // Validate: alternatives-cases doesn't support waypoints, only numeric subslide specs
+ for case in cases {
+ if type(case) == label {
+ panic(
+ "alternatives-cases: waypoint labels are not supported. Use alternatives() with the at: parameter instead.",
+ )
+ }
+ if (
+ type(case) == dictionary
+ and "kind" in case
+ and str(case.kind) in waypoint-kinds
+ ) {
+ panic(
+ "alternatives-cases: waypoint markers are not supported. Use alternatives() with the at: parameter instead.",
+ )
+ }
+ }
+ touying-fn-wrapper(
+ utils.alternatives-cases,
+ last-subslide: calc.max(..cases.map(utils.last-required-subslide)),
+ cases,
+ fn,
+ position: position,
+ stretch: stretch,
+ ..kwargs,
+ )
+}
+
+
+/// Display list, enum, or terms items one by one with animation.
+///
+/// Each item is revealed on a successive subslide. By default (`start: auto`),
+/// revealing is relative to the current pause position — items appear one per
+/// subslide starting from wherever the slide's animation has reached.
+///
+/// `start` also accepts a waypoint label (e.g. `<my-wp>`) or any waypoint
+/// marker (`from-wp(<wp>)`, `get-first(<wp>)`, etc.) to anchor the reveal
+/// sequence to a named position.
+///
+/// == Examples
+///
+/// ```typst
+/// // Relative (auto) — items appear after any preceding #pause
+/// #item-by-item[
+/// - first
+/// - second
+/// - third
+/// ]
+///
+/// // Anchored to a waypoint
+/// #waypoint(<items>)
+/// #item-by-item(start: <items>)[
+/// - alpha
+/// - beta
+/// ]
+///
+/// // Explicit absolute subslide number (backward compatible)
+/// #item-by-item(start: 3)[
+/// - x
+/// - y
+/// ]
+/// ```
+///
+/// - start (auto | int | label | dictionary): The subslide on which the first
+/// item appears. `auto` (default) makes it relative to the current pause
+/// position. An integer gives an absolute subslide number. A label or
+/// waypoint marker resolves to the waypoint's first subslide.
+///
+/// - cont (content): The content containing a list, enum, or terms element.
+///
+/// -> content
+#let item-by-item(start: auto, cont) = {
+ if (
+ type(start) == dictionary
+ and start.at("kind", default: none)
+ in ("touying-waypoint-from", "touying-waypoint-until")
+ ) {
+ panic(
+ "item-by-item: `start` must resolve to a single subslide position. "
+ + "`from-wp` and `until-wp` are range markers and are not supported here. "
+ + "Use a label, `get-first`, `get-last`, `prev-wp`, `next-wp` or simple slide numbers instead.",
+ )
+ }
+ let num-items = if utils.is-sequence(cont) {
+ cont
+ .children
+ .filter(c => (
+ type(c) == content and c.func() in (list.item, enum.item, terms.item)
+ ))
+ .len()
+ } else if cont.func() in (list, enum, terms) {
+ cont.children.len()
+ } else {
+ 1
+ }
+ if start == auto {
+ // Relative: items start from the current pause position.
+ touying-fn-wrapper(
+ utils.item-by-item,
+ last-subslide: repetitions => (
+ repetitions + num-items - 1,
+ (start: repetitions),
+ ),
+ start: start,
+ cont,
+ )
+ } else if type(start) == int {
+ touying-fn-wrapper(
+ utils.item-by-item,
+ last-subslide: start + num-items - 1,
+ start: start,
+ cont,
+ )
+ } else if type(start) == str {
+ let parts = utils._parse-subslide-indices(start)
+ if parts.len() != 1 or type(parts.first()) != int {
+ panic(
+ "item-by-item: `start` string must be a single number (e.g. \"3\"), "
+ + "not a range or multi-value spec. Got: \""
+ + start
+ + "\".",
+ )
+ }
+ let n = parts.first()
+ touying-fn-wrapper(
+ utils.item-by-item,
+ last-subslide: n + num-items - 1,
+ start: n,
+ cont,
+ )
+ } else {
+ // Label or waypoint marker — resolved at render time.
+ // For a plain label, emit an implicit waypoint so users don't need to write
+ // a separate #waypoint(<label>) before the call. The _waypoint-known check
+ // in the prepass ensures the waypoint is only registered once even if an
+ // explicit #waypoint(<label>) is also present.
+ // Dictionary markers (from-wp, next-wp, get-first, …) reference an existing
+ // explicit waypoint, so no implicit waypoint is needed for those.
+ if type(start) == label {
+ [#metadata((
+ kind: "touying-implicit-waypoint",
+ label: str(start),
+ ))<touying-temporary-mark>]
+ }
+ // At callback time, `repetitions` equals the waypoint's subslide number
+ // (the implicit or explicit waypoint was processed just before this wrapper).
+ // We need num-items subslides starting from there, so the last subslide
+ // needed is repetitions + num-items - 1.
+ // Return empty extra-args so the original label/marker `start` is preserved
+ // for render-time resolution via resolve-waypoints.
+ touying-fn-wrapper(
+ utils.item-by-item,
+ last-subslide: repetitions => (
+ repetitions + num-items - 1,
+ (:),
+ ),
+ start: start,
+ cont,
+ )
+ }
+}
+
+/// Makes the currently revealed item bold. You may pass it an optional `weight` parameter. See https://typst.app/docs/reference/text/text/#parameters-weight. Default is `"bold"`.
+///
+/// - time (int): The relative subslide index passed by item-by-item-fn.
+/// - it (content): The item passed by item-by-item-fn.
+/// - weight (str): The weight of the bold text. Default is `"bold"`.
+/// -> content
+#let current-bold(time, it, weight: "bold") = {
+ if time == 0 {
+ text(weight: weight, it)
+ } else {
+ it
+ }
+}
+/// Highlights the currently revealed item with a yellow background. You may give it an optional `style` parameter (dictionary) to customize the highlight style. See https://typst.app/docs/reference/text/highlight/.
+///
+/// - time (int): The relative subslide index passed by item-by-item-fn.
+/// - it (content): The item passed by item-by-item-fn.
+/// - style (dictionary): The style parameters for the highlight.
+/// -> content
+#let current-highlight(time, it, style: (fill: rgb("#fffd11a1"))) = {
+ if time == 0 {
+ highlight(..style, it)
+ } else {
+ it
+ }
+}
+/// Fades already revealed items by reducing their fill alpha. You may pass it an optional `alpha` parameter, default is `20%`.
+///
+/// - time (int): The relative subslide index passed by item-by-item-fn.
+/// - it (content): The item passed by item-by-item-fn.
+/// - alpha (float): The alpha value for the fade effect. Default is `20%`.
+/// -> content
+#let past-faded(time, it, alpha: 20%) = context {
+ if time < 0 {
+ text(fill: utils.update-alpha(text.fill, alpha), it)
+ } else {
+ it
+ }
+}
+/// Fades already revealed items with a progressive fade. You may pass it an optional `alpha` dict. Possible keys are `linear` and `exponential` for linear and exponential fading, respectively. The value is the fading speed. Default is `(linear: 30%)`.
+///
+/// - time (int): The relative subslide index passed by item-by-item-fn.
+/// - it (content): The item passed by item-by-item-fn.
+/// - alpha (dictionary): The fade speed and type, higher means faster fading. Either `(linear: <value>)` or `(exponential: <value>)`. Default is `(linear: 30%)`.
+/// -> content
+#let past-progressive-faded(time, it, alpha: (linear: 30%)) = context {
+ assert(
+ type(alpha) == dictionary
+ and alpha.keys().len() == 1
+ and alpha.keys().first() in ("linear", "exponential"),
+ message: "Invalid alpha spec for past-progressive-faded. Expected {linear: <value>} or {exponential: <value>}, got "
+ + repr(alpha),
+ )
+
+ let fade = if "linear" in alpha.keys() {
+ calc.clamp(1 + time * float(alpha.linear), 0, 1)
+ } else {
+ // exponential
+ calc.clamp(calc.pow(1 - float(alpha.exponential), -time), 0, 1)
+ }
+ text(fill: utils.update-alpha(text.fill, fade * 100%), it)
+}
+/// Styling presets for item-by-item-fn. Available ones are:
+/// - `current-bold`: Makes the currently revealed item bold. You may pass it an optional `weight` parameter. See https://typst.app/docs/reference/text/text/#parameters-weight. Default is `"bold"`.
+/// - `current-highlight`: Highlights the currently revealed item with a yellow background. You may give it an optional `style` parameter (dictionary) to customize the highlight style. See https://typst.app/docs/reference/text/highlight/.
+/// - `past-faded`: Fades already revealed items by reducing their fill alpha. You may pass it an optional `alpha` parameter, default is `20%`.
+/// - `past-progressive-faded`: Fades already revealed items with a progressive fade. You may pass it an optional `alpha` dict. Possible keys are `linear` and `exponential` for linear and exponential fading, respectively. The value is the fading speed. Default is `(linear: 30%)`.
+/// See the respective functions for more details.
+///
+/// Usage:\
+/// `#item-by-item-fn("current-highlight")[ ... ]` \
+/// or if you want to customize the style: \
+/// `#item-by-item-fn(item-by-item-functions.at("current-highlight").with(style: (stroke: red)))[ ... ]` or \
+/// `#item-by-item-fn((item-by-item-functions.current-highlight).with(style: (stroke: red)))[ ... ]`
+#let item-by-item-functions = (
+ current-bold: current-bold,
+ current-highlight: current-highlight,
+ past-faded: past-faded,
+ past-progressive-faded: past-progressive-faded,
+)
+
+/// Display list, enum, or terms items one by one with animation and styling.
+/// For basic details, see `#item-by-item`, this is a more customizable version that accepts a styling function to style each item depending on whether it's being revealed, already revealed, or still hidden.
+///
+/// - start (auto | int | label | dictionary): The subslide on which the first
+/// item appears. `auto` (default) makes it relative to the current pause
+/// position. An integer gives an absolute subslide number. A label or
+/// waypoint marker resolves to the waypoint's first subslide.
+///
+/// - fn (function, str): A styling function that styles each list element. It receives `(time (int), it)` where `time` is the relative subslide index, i.e. it may be negative, 0 or positive depending on whether the item was revealed, is being revealed or will be revealed in the future. If none, this defaults to the normal `item-by-item` behavior of simply revealing items without additional styling. Note that this does not interfere with the normal cover mechanism. \ We support several presets for this: `"current-bold"`, `"current-highlight"`, `"past-faded"`, `"past-progressive-faded"` available either by passing in these strings or in the dictionary `item-by-item-functions`. The latter let's you customize the presets.
+///
+/// - cont (content): The content containing a list, enum, or terms element.
+///
+/// -> content
+#let item-by-item-fn(start: auto, fn, cont) = {
+ if type(fn) == str {
+ assert(
+ fn in item-by-item-functions.keys(),
+ message: "Unknown preset for item-by-item-fn: "
+ + repr(fn)
+ + ". Available presets are: "
+ + repr(item-by-item-functions.keys()),
+ )
+ fn = item-by-item-functions.at(fn)
+ }
+ assert(
+ type(fn) in (function, type(none)),
+ message: "item-by-item-fn: `fn` must be a function or a preset name string, got "
+ + repr(type(fn)),
+ )
+
+ if (
+ type(start) == dictionary
+ and start.at("kind", default: none)
+ in ("touying-waypoint-from", "touying-waypoint-until")
+ ) {
+ panic(
+ "item-by-item-fn: `start` must resolve to a single subslide position. "
+ + "`from-wp` and `until-wp` are range markers and are not supported here. "
+ + "Use a label, `get-first`, `get-last`, `prev-wp`, `next-wp` or simple slide numbers instead.",
+ )
+ }
+ let num-items = if utils.is-sequence(cont) {
+ cont
+ .children
+ .filter(c => (
+ type(c) == content and c.func() in (list.item, enum.item, terms.item)
+ ))
+ .len()
+ } else if cont.func() in (list, enum, terms) {
+ cont.children.len()
+ } else {
+ 1
+ }
+ if start == auto {
+ touying-fn-wrapper(
+ utils.item-by-item-fn,
+ last-subslide: repetitions => (
+ repetitions + num-items - 1,
+ (start: repetitions),
+ ),
+ start: start,
+ fn,
+ cont,
+ )
+ } else if type(start) == int {
+ touying-fn-wrapper(
+ utils.item-by-item-fn,
+ last-subslide: start + num-items - 1,
+ start: start,
+ fn,
+ cont,
+ )
+ } else if type(start) == str {
+ let parts = utils._parse-subslide-indices(start)
+ if parts.len() != 1 or type(parts.first()) != int {
+ panic(
+ "item-by-item-fn: `start` string must be a single number (e.g. \"3\"), "
+ + "not a range or multi-value spec. Got: \""
+ + start
+ + "\".",
+ )
+ }
+ let n = parts.first()
+ touying-fn-wrapper(
+ utils.item-by-item-fn,
+ last-subslide: n + num-items - 1,
+ start: n,
+ fn,
+ cont,
+ )
+ } else {
+ if type(start) == label {
+ [#metadata((
+ kind: "touying-implicit-waypoint",
+ label: str(start),
+ ))<touying-temporary-mark>]
+ }
+ touying-fn-wrapper(
+ utils.item-by-item-fn,
+ last-subslide: repetitions => (
+ repetitions + num-items - 1,
+ (:),
+ ),
+ start: start,
+ fn,
+ cont,
+ )
+ }
+}
+
+/// Speaker notes are a way to add additional information to your slides that is not visible to the audience. This can be useful for providing additional context or reminders to yourself.
+///
+/// Multiple calls on the same slide are combined (accumulated), so all notes are shown together.
+///
+/// Example:
+///
+/// ```typ
+/// #speaker-note[This is a speaker note]
+/// ```
+///
+/// - mode (str): The mode of the markup text, either `typ` or `md`. Default is `typ`.
+///
+/// - setting (function): A function that takes the note as input and returns a processed note.
+///
+/// - subslide (none, auto, int, array, str): Restricts the note to specific subslides, similar to `only`.
+/// - `auto` (default): automatically determined from the current pause position. A note placed after `#pause` will automatically appear only from that subslide onward.
+/// - `none`: shown on all subslides regardless of position.
+/// - int, array, or string: shown only on the specified subslides.
+///
+/// - note (content): The content of the speaker note. May contain `#pause` to reveal parts progressively.
+///
+/// -> content
+#let speaker-note(
+ mode: "typ",
+ setting: it => it,
+ subslide: auto,
+ note,
+) = [#metadata((
+ kind: "touying-speaker-note",
+ mode: mode,
+ setting: setting,
+ subslide: subslide,
+ note: note,
+))<touying-temporary-mark>]
+
+
+/// Alert is a way to display a message to the audience. It can be used to draw attention to important information or to provide instructions.
+///
+/// -> content
+#let alert(body) = touying-fn-wrapper-raw(utils.alert, body)
+
+
+/// Animated math equation. Use `pause` and `meanwhile` inside the equation body to reveal terms step by step.
+///
+/// Write the equation as a raw block (backtick string) or a plain string. Use `pause` (without backslash or `#`) as a pseudo-command inside the equation to insert a pause marker.
+///
+/// #example(
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+/// = Slide
+///
+/// #touying-equation(`
+/// f(x) &= pause x^2 + 2x + 1 \
+/// &= pause (x + 1)^2
+/// `)
+/// )
+///
+/// - block (bool): Whether the equation is a block element. Default is `true`.
+///
+/// - numbering (none, str): The numbering of the equation. Default is `none`.
+///
+/// - supplement (auto, str): The supplement of the equation. Default is `auto`.
+///
+/// - scope (dictionary): Extra bindings passed to `eval()` when the body is a string or raw block.
+///
+/// - body (str, content, function): The equation content. Accepts a raw block (e.g. `` `f(x) = pause x^2` ``), a plain string, or a callback `self => str`.
+///
+/// -> content
+#let touying-equation(
+ block: true,
+ numbering: none,
+ supplement: auto,
+ scope: (:),
+ body,
+) = [#metadata((
+ kind: "touying-equation",
+ block: block,
+ numbering: numbering,
+ supplement: supplement,
+ scope: scope,
+ body: {
+ if type(body) == function {
+ body
+ } else if type(body) == str {
+ body
+ } else if type(body) == content and body.has("text") {
+ body.text
+ } else {
+ panic("Unsupported type: " + str(type(body)))
+ }
+ },
+))<touying-temporary-mark>]
+
+
+/// Touying can integrate with `mitex` to display math equations.
+/// You can use `#touying-mitex` to display math equations with pause and meanwhile.
+///
+/// Example:
+///
+/// ```typst
+/// #touying-mitex(mitex, `
+/// f(x) &= \pause x^2 + 2x + 1 \\
+/// &= \pause (x + 1)^2 \\
+/// `)
+/// ```
+///
+/// - mitex (function): The mitex function. You can import it by code like `#import "@preview/mitex:0.2.6": mitex`.
+///
+/// - block (bool): Whether the equation is a block element. Default is `true`.
+///
+/// - numbering (none, str): The numbering of the equation. Default is `none`.
+///
+/// - supplement (auto, str): The supplement of the equation. Default is `auto`.
+///
+/// - body (string, content, function): The content of the equation. It should be a string, a raw text, or a function that receives `self` as an argument and returns a string.
+///
+/// -> content
+#let touying-mitex(
+ block: true,
+ numbering: none,
+ supplement: auto,
+ mitex,
+ body,
+) = [#metadata((
+ kind: "touying-mitex",
+ block: block,
+ numbering: numbering,
+ supplement: supplement,
+ mitex: mitex,
+ body: {
+ if type(body) == function {
+ body
+ } else if type(body) == str {
+ body
+ } else if type(body) == content and body.has("text") {
+ body.text
+ } else {
+ panic("Unsupported type: " + str(type(body)))
+ }
+ },
+))<touying-temporary-mark>]
+
+
+/// Animated code block. Use a comment-style `pause` or `meanwhile` on its own line to insert animation markers.
+///
+/// A line is treated as a `pause` or `meanwhile` marker when its only
+/// meaningful characters (letters, digits, CJK) exactly spell "pause" or
+/// "meanwhile". For example, `// pause`, `# pause`, and `#pause` are all
+/// valid markers, while `pause = 1` or `def pause():` are not.
+///
+/// #example(
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+/// = Slide
+///
+/// #touying-raw(```rust
+/// fn main() {
+/// // pause
+/// println!("Hello, world!");
+/// }
+/// ```)
+/// )
+///
+/// - block (bool): Whether the raw block is a block element. Default is `true`.
+///
+/// - lang (none, str): The language for syntax highlighting. When `none`, the language is inferred from the raw block body if possible. Default is `none`.
+///
+/// - fill-empty-lines (bool): Whether to replace hidden lines with empty lines to preserve the layout of visible lines. Default is `true`.
+///
+/// - simple (bool): When `true`, use `#pause;` and `#meanwhile;` as direct split markers (similar to how `touying-mitex` uses `\pause`). Default is `false`.
+///
+/// - body (str, content, function): The raw code content. Can be a raw block, a string, or a function receiving `self` as an argument.
+///
+/// -> content
+#let touying-raw(
+ block: true,
+ lang: none,
+ fill-empty-lines: true,
+ simple: false,
+ body,
+) = [#metadata((
+ kind: "touying-raw",
+ block: if type(body) == content and body.has("block") { body.block } else {
+ block
+ },
+ lang: if lang == none and type(body) == content and body.has("lang") {
+ body.lang
+ } else {
+ lang
+ },
+ fill-empty-lines: fill-empty-lines,
+ simple: simple,
+ body: {
+ if type(body) == function {
+ body
+ } else if type(body) == str {
+ body
+ } else if type(body) == content and body.has("text") {
+ body.text
+ } else {
+ panic("Unsupported type: " + str(type(body)))
+ }
+ },
+))<touying-temporary-mark>]
+
+
+/// Extend external packages with `pause` and `meanwhile` animation support.
+///
+/// Wraps an external drawing/diagram function (like `cetz.canvas` or `fletcher.diagram`) so that Touying can intercept `pause`/`meanwhile` markers inside its content array and hide/cover items across subslides.
+///
+/// Define package-specific wrappers once at the top of your document:
+///
+/// ```typst
+/// // CeTZ
+/// #let cetz-canvas = touying-reducer.with(
+/// reduce: cetz.canvas,
+/// cover: cetz.draw.hide.with(bounds: true),
+/// )
+///
+/// // Fletcher
+/// #let fletcher-diagram = touying-reducer.with(
+/// reduce: fletcher.diagram,
+/// cover: fletcher.hide,
+/// )
+/// ```
+///
+/// - reduce (function): The external drawing function. It should accept an array of drawing commands and return rendered content (e.g. `cetz.canvas` or `fletcher.diagram`).
+///
+/// - cover (function): Called with a drawing command when that command should be hidden on the current subslide. Should produce invisible but space-preserving content (e.g. `cetz.draw.hide.with(bounds: true)` or `fletcher.hide`).
+///
+/// - args (arguments): The positional and named arguments passed to the `reduce` function.
+///
+/// -> content
+#let touying-reducer(
+ reduce: arr => arr.sum(),
+ cover: arr => none,
+ ..args,
+) = [#metadata((
+ kind: "touying-reducer",
+ reduce: reduce,
+ cover: cover,
+ kwargs: args.named(),
+ args: args.pos(),
+))<touying-temporary-mark>]
+
+
+/// Parse touying equation content and extract animation repetitions
+///
+/// Processes equation content with pause and meanwhile markers, returning
+/// the parsed equation and the total number of repetitions needed.
+///
+/// - self (dictionary): The presentation context
+/// - need-cover (bool): Whether hidden content should be covered
+/// - base (int): Base repetition count
+/// - index (int): Current subslide index
+/// - eqt-metadata (content): The equation metadata to parse
+///
+/// -> (array, int)
+#let _parse-touying-equation(
+ self: none,
+ need-cover: true,
+ base: 1,
+ index: 1,
+ eqt-metadata,
+) = {
+ let eqt = eqt-metadata.value
+ let parsed-results = ()
+ // repetitions
+ let repetitions = base
+ let max-repetitions = repetitions
+ // get cover function from self
+ let cover = self.methods.cover.with(self: self)
+ // get eqt body
+ let it = eqt.body
+ // if it is a function, then call it with self
+ if type(it) == function {
+ it = it(self)
+ }
+ assert(type(it) == str, message: "Unsupported type: " + str(type(it)))
+ // parse the content
+ let result = ()
+ let hidden-parts = ()
+ let children = it
+ .split(regex("(#meanwhile;?)|(meanwhile)"))
+ .intersperse("touying-meanwhile")
+ .map(s => s.split(regex("(#pause;?)|(pause)")).intersperse("touying-pause"))
+ .flatten()
+ .map(s => s.split(regex("(\\\\\\s)|(\\\\\\n)")).intersperse("\\\n"))
+ .flatten()
+ .map(s => s.split(regex("&")).intersperse("&"))
+ .flatten()
+ for child in children {
+ if child == "touying-pause" {
+ repetitions += 1
+ } else if child == "touying-meanwhile" {
+ // clear the hidden-parts when encounter #meanwhile
+ if hidden-parts.len() != 0 {
+ result.push("cover(" + hidden-parts.sum() + ")")
+ hidden-parts = ()
+ }
+ // then reset the repetitions
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = 1
+ } else if child == "\\\n" or child == "&" {
+ // clear the hidden-parts when encounter linebreak or parbreak
+ if hidden-parts.len() != 0 {
+ result.push("cover(" + hidden-parts.sum() + ")")
+ hidden-parts = ()
+ }
+ result.push(child)
+ } else {
+ if repetitions <= index or not need-cover {
+ result.push(child)
+ } else {
+ hidden-parts.push(child)
+ }
+ }
+ }
+ // clear the hidden-parts when end
+ if hidden-parts.len() != 0 {
+ result.push("cover(" + hidden-parts.sum() + ")")
+ hidden-parts = ()
+ }
+ let equation = math.equation(
+ block: eqt.block,
+ numbering: eqt.numbering,
+ supplement: eqt.supplement,
+ eval(
+ "$" + result.sum(default: "") + "$",
+ scope: eqt.scope
+ + (
+ cover: (..args) => {
+ let cover = eqt.scope.at("cover", default: cover)
+ if args.pos().len() != 0 {
+ cover(args.pos().first())
+ }
+ },
+ ),
+ ),
+ )
+ if (
+ eqt-metadata.has("label") and eqt-metadata.label != <touying-temporary-mark>
+ ) {
+ equation = [#equation#eqt-metadata.label]
+ }
+ parsed-results.push(equation)
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ return (parsed-results, max-repetitions)
+}
+
+/// Parse touying mitex content and extract animation repetitions
+///
+/// Similar to _parse-touying-equation but for MiTeX equations.
+///
+/// - self (dictionary): The presentation context
+/// - need-cover (bool): Whether hidden content should be covered
+/// - base (int): Base repetition count
+/// - index (int): Current subslide index
+/// - eqt-metadata (content): The mitex metadata to parse
+///
+/// -> (array, int)
+#let _parse-touying-mitex(
+ self: none,
+ need-cover: true,
+ base: 1,
+ index: 1,
+ eqt-metadata,
+) = {
+ let eqt = eqt-metadata.value
+ let parsed-results = ()
+ // repetitions
+ let repetitions = base
+ let max-repetitions = repetitions
+ // get eqt body
+ let it = eqt.body
+ // if it is a function, then call it with self
+ if type(it) == function {
+ it = it(self)
+ }
+ assert(type(it) == str, message: "Unsupported type: " + str(type(it)))
+ // parse the content
+ let result = ()
+ let hidden-parts = ()
+ let children = it
+ .split(regex("\\\\meanwhile"))
+ .intersperse("touying-meanwhile")
+ .map(s => s.split(regex("\\\\pause")).intersperse("touying-pause"))
+ .flatten()
+ .map(s => s.split(regex("(\\\\\\\\\s)|(\\\\\\\\\n)")).intersperse("\\\\\n"))
+ .flatten()
+ .map(s => s.split(regex("&")).intersperse("&"))
+ .flatten()
+ for child in children {
+ if child == "touying-pause" {
+ repetitions += 1
+ } else if child == "touying-meanwhile" {
+ // clear the hidden-parts when encounter #meanwhile
+ if hidden-parts.len() != 0 {
+ result.push("\\phantom{" + hidden-parts.sum() + "}")
+ hidden-parts = ()
+ }
+ // then reset the repetitions
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = 1
+ } else if child == "\\\n" or child == "&" {
+ // clear the hidden-parts when encounter linebreak or parbreak
+ if hidden-parts.len() != 0 {
+ result.push("\\phantom{" + hidden-parts.sum() + "}")
+ hidden-parts = ()
+ }
+ result.push(child)
+ } else {
+ if repetitions <= index or not need-cover {
+ result.push(child)
+ } else {
+ hidden-parts.push(child)
+ }
+ }
+ }
+ // clear the hidden-parts when end
+ if hidden-parts.len() != 0 {
+ result.push("\\phantom{" + hidden-parts.sum() + "}")
+ hidden-parts = ()
+ }
+ let equation = (eqt.mitex)(
+ block: eqt.block,
+ numbering: eqt.numbering,
+ supplement: eqt.supplement,
+ result.sum(default: ""),
+ )
+ if (
+ eqt-metadata.has("label") and eqt-metadata.label != <touying-temporary-mark>
+ ) {
+ equation = [#equation#eqt-metadata.label]
+ }
+ parsed-results.push(equation)
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ return (parsed-results, max-repetitions)
+}
+
+/// Parse touying raw content and extract animation repetitions
+///
+/// Processes raw code block content with pause and meanwhile markers, returning
+/// the rendered raw block and the total number of repetitions needed.
+///
+/// A line acts as a pause or meanwhile marker when every meaningful character
+/// on that line (letters, digits, CJK) spells exactly "pause" or "meanwhile".
+/// This allows markers like `// pause`, `# pause`, or `#pause` while ignoring
+/// lines like `pause = 1` or `def pause():`.
+///
+/// - self (dictionary): The presentation context
+/// - need-cover (bool): Whether hidden content should be covered
+/// - base (int): Base repetition count
+/// - index (int): Current subslide index
+/// - raw-metadata (content): The raw metadata to parse
+///
+/// -> (array, int)
+#let _parse-touying-raw(
+ self: none,
+ need-cover: true,
+ base: 1,
+ index: 1,
+ raw-metadata,
+) = {
+ let raw-data = raw-metadata.value
+ // Pattern matching meaningful characters: letters, digits, and CJK Unified Ideographs
+ let meaningful-chars-pattern = regex("[a-zA-Z0-9\u{4E00}-\u{9FFF}]+")
+ let parsed-results = ()
+ let repetitions = base
+ let max-repetitions = repetitions
+ let it = raw-data.body
+ if type(it) == function {
+ it = it(self)
+ }
+ assert(type(it) == str, message: "Unsupported type: " + str(type(it)))
+
+ let result-text = ""
+
+ if raw-data.simple {
+ // Simple mode: split directly on #pause; and #meanwhile; markers.
+ // Markers may appear anywhere in the text (including inline), so we work
+ // directly with text segments rather than splitting into lines first —
+ // that would introduce spurious newlines when markers are inline.
+ let text-parts = ()
+ let parts = it
+ .split(regex("#meanwhile;"))
+ .intersperse("touying-meanwhile")
+ .map(s => s.split(regex("#pause;")).intersperse("touying-pause"))
+ .flatten()
+ for part in parts {
+ if part == "touying-pause" {
+ repetitions += 1
+ } else if part == "touying-meanwhile" {
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = 1
+ } else {
+ if repetitions <= index or not need-cover {
+ text-parts.push(part)
+ } else if raw-data.fill-empty-lines {
+ // Preserve line structure: keep newlines, erase all other characters
+ text-parts.push(part.replace(regex("[^\n]+"), ""))
+ }
+ }
+ }
+ result-text = text-parts.join("")
+ } else {
+ // Normal mode: process line by line.
+ // A line is a pause/meanwhile marker when its only meaningful characters
+ // (letters, digits, CJK Unified Ideographs) spell exactly "pause" or "meanwhile"
+ let result-lines = ()
+ let lines = it.split("\n")
+ for line in lines {
+ let meaningful = line
+ .matches(meaningful-chars-pattern)
+ .map(m => m.text)
+ .join("")
+ if meaningful == "pause" {
+ repetitions += 1
+ } else if meaningful == "meanwhile" {
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = 1
+ } else if repetitions <= index or not need-cover {
+ result-lines.push(line)
+ } else if raw-data.fill-empty-lines {
+ result-lines.push("")
+ }
+ }
+ result-text = result-lines.join("\n")
+ }
+ let raw-block = raw(result-text, lang: raw-data.lang, block: raw-data.block)
+ if (
+ raw-metadata.has("label") and raw-metadata.label != <touying-temporary-mark>
+ ) {
+ raw-block = [#raw-block#raw-metadata.label]
+ }
+ parsed-results.push(raw-block)
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ return (parsed-results, max-repetitions)
+}
+
+/// Parse touying reducer content and extract animation repetitions
+///
+/// Processes reducer content (used for external packages like CeTZ, Fletcher)
+/// with pause and meanwhile markers.
+///
+/// - self (dictionary): The presentation context
+/// - base (int): Base repetition count
+/// - index (int): Current subslide index
+/// - reducer (dictionary): The reducer configuration
+///
+/// -> (array, int)
+#let _parse-touying-reducer(self: none, base: 1, index: 1, reducer) = {
+ let parsed-results = ()
+ // repetitions
+ let repetitions = base
+ let max-repetitions = repetitions
+ let last-subslide = 0
+ // get cover function from self
+ let cover = reducer.cover
+ // Build a modified self whose cover method uses the reducer's cover function,
+ // so that fn-wrappers (uncover, only, etc.) cover items correctly for the
+ // external package (e.g. fletcher.hide instead of the global hide).
+ let reducer-self = utils.merge-dicts(
+ self,
+ (
+ methods: utils.merge-dicts(
+ self.at("methods", default: (:)),
+ (cover: utils.method-wrapper(reducer.cover)),
+ ),
+ ),
+ )
+ // parse the content
+ // Flatten content sequences so that e.g. uncover(<label>, body) which produces
+ // [implicit-waypoint-metadata + fn-wrapper-metadata] is split into separate children.
+ let flat-args = ()
+ for arg in reducer.args.flatten() {
+ if type(arg) == content and utils.is-sequence(arg) {
+ flat-args += arg.children
+ } else {
+ flat-args.push(arg)
+ }
+ }
+ let result = ()
+ let hidden-parts = ()
+ for child in flat-args {
+ if (
+ type(child) == content
+ and child.func() == metadata
+ and type(child.value) == dictionary
+ ) {
+ let kind = child.value.at("kind", default: none)
+ if kind == "touying-jump/pause/meanwhile" {
+ if child.value.relative {
+ // Snap past any fn-wrapper range before applying the relative jump
+ repetitions = calc.max(repetitions, last-subslide) + child.value.n
+ // Track the peak repetitions so that a subsequent negative jump doesn't
+ // cause the slide count to be underestimated
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ // If we jumped back into the visible zone, flush hidden-parts in order
+ // (so they appear before subsequent visible content, not after it)
+ if hidden-parts.len() != 0 and repetitions <= index {
+ let r = cover(hidden-parts)
+ if type(r) == array {
+ result += r
+ } else {
+ result.push(r)
+ }
+ hidden-parts = ()
+ }
+ } else {
+ // absolute jump: clear hidden-parts and jump to target subslide
+ if hidden-parts.len() != 0 {
+ let r = cover(hidden-parts)
+ if type(r) == array {
+ result += r
+ } else {
+ result.push(r)
+ }
+ }
+ hidden-parts = ()
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = child.value.n
+ last-subslide = 0
+ }
+ } else if kind == "touying-waypoint" {
+ //support only implicit or explicit waypoints in reducer, no waypoint markers for now
+ // Waypoint inside reducer: never pushed to result or hidden-parts.
+ let wp = self.at("waypoints", default: (:))
+ let lbl = child.value.label
+ let wp-start = child.value.at("start", default: auto)
+ if wp-start != auto and lbl in wp {
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = wp.at(lbl).first
+ last-subslide = 0
+ } else if (
+ child.value.at("advance", default: true) and lbl in wp
+ ) {
+ let first = wp.at(lbl).first
+ if (
+ first == repetitions + 1
+ or (first == last-subslide + 1 and first > repetitions)
+ ) {
+ repetitions = first
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ }
+ } else if kind == "touying-implicit-waypoint" {
+ // Implicit waypoint inside reducer: same firing logic as the outer parser.
+ let wp = self.at("waypoints", default: (:))
+ let lbl = child.value.label
+ if lbl in wp {
+ let first = wp.at(lbl).first
+ if (
+ first == repetitions + 1
+ or (first == last-subslide + 1 and first > repetitions)
+ ) {
+ repetitions = first
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ }
+ } else if kind == "touying-fn-wrapper" {
+ // Handle function wrappers (uncover, only, alternatives, etc.)
+ // These always escape the pause zone: they handle their own visibility.
+ let extra-args = (:)
+ if child.value.last-subslide != none {
+ if type(child.value.last-subslide) == function {
+ let (callback-last-subslide, callback-extra-args) = (
+ child.value.last-subslide
+ )(
+ repetitions,
+ )
+ last-subslide = calc.max(last-subslide, callback-last-subslide)
+ extra-args = callback-extra-args
+ } else {
+ last-subslide = calc.max(last-subslide, child.value.last-subslide)
+ }
+ }
+ let fn-result = (child.value.fn)(
+ self: reducer-self,
+ ..child.value.args,
+ ..extra-args,
+ )
+ // only() returns none when hidden — don't push none to the result.
+ // Flatten arrays (CeTZ draw commands) and content sequences (e.g.
+ // alternatives returning joined only() results) so the reduce function
+ // sees the same flat items as it would in the callback pathway.
+ if fn-result != none {
+ if type(fn-result) == array {
+ result += fn-result
+ } else if (
+ type(fn-result) == content and utils.is-sequence(fn-result)
+ ) {
+ for child in fn-result.children {
+ result.push(child)
+ }
+ } else {
+ result.push(fn-result)
+ }
+ }
+ } else {
+ //automatically collects raw fn wrapper
+ if repetitions <= index {
+ result.push(child)
+ } else {
+ hidden-parts.push(child)
+ }
+ }
+ } else {
+ if repetitions <= index {
+ result.push(child)
+ } else {
+ hidden-parts.push(child)
+ }
+ }
+ }
+ // clear the hidden-parts when end
+ if hidden-parts.len() != 0 {
+ let r = cover(hidden-parts)
+ if type(r) == array {
+ result += r
+ } else {
+ result.push(r)
+ }
+ }
+ hidden-parts = ()
+ // Safety net: filter out any remaining touying metadata nodes before passing
+ // to the external reduce function (e.g. fletcher.diagram, cetz.canvas).
+ // All touying metadata should already be handled above — if this filter
+ // catches anything, it indicates a bug in the reducer's metadata handling.
+ let leaked = result.filter(child => {
+ if not (
+ type(child) == content
+ and child.func() == metadata
+ and type(child.value) == dictionary
+ ) {
+ return false
+ }
+ let kind = child.value.at("kind", default: none)
+ type(kind) == str and kind.starts-with("touying-")
+ })
+ if leaked.len() > 0 {
+ let kinds = leaked.map(c => c.value.at("kind", default: "unknown"))
+ assert(
+ false,
+ message: "touying internal bug: leaked metadata into reducer result: "
+ + repr(kinds)
+ + ". Please report this at https://github.com/touying-typ/touying/issues",
+ )
+ }
+ parsed-results.push(
+ (reducer.reduce)(
+ ..reducer.kwargs,
+ result,
+ ),
+ )
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ max-repetitions = calc.max(max-repetitions, last-subslide)
+ return (parsed-results, max-repetitions)
+}
+
+
+/// ------------------------------------------------
+/// Waypoint Collection
+/// ------------------------------------------------
+
+/// Check whether a waypoint label is already known — either exactly or
+/// because a child in the hierarchy was registered earlier (e.g. `<top:sub>`
+/// makes `<top>` known without storing a synthetic parent entry).
+#let _waypoint-known(waypoints, lbl) = {
+ if lbl in waypoints {
+ return true
+ }
+ let prefix = lbl + ":"
+ waypoints.keys().any(k => k.starts-with(prefix))
+}
+
+/// Count the peak repetition produced by an animated block (touying-equation,
+/// touying-mitex, touying-raw, touying-reducer). Returns the max-repetitions
+/// value, mirroring what the corresponding `_parse-touying-*` function would
+/// return without needing `self` or cover logic.
+///
+/// - kind (str): The metadata kind.
+/// - value (dictionary): The metadata value.
+/// - base (int): The starting repetition count.
+///
+/// -> int
+#let _count-animated-block-repetitions(kind, value, base) = {
+ let repetitions = base
+ let max-repetitions = repetitions
+
+ if kind == "touying-reducer" {
+ let last-subslide = 0
+ // Reducer: iterate positional args looking for touying-jump/pause/meanwhile,
+ // touying-waypoint, and touying-fn-wrapper metadata.
+ // Flatten content sequences so that e.g. uncover(<label>, body) which produces
+ // [implicit-waypoint-metadata + fn-wrapper-metadata] is split into separate children.
+ let flat-count-args = ()
+ for arg in value.args.flatten() {
+ if type(arg) == content and utils.is-sequence(arg) {
+ flat-count-args += arg.children
+ } else {
+ flat-count-args.push(arg)
+ }
+ }
+ for child in flat-count-args {
+ if (
+ type(child) == content
+ and child.func() == metadata
+ and type(child.value) == dictionary
+ ) {
+ let k = child.value.at("kind", default: none)
+ if k == "touying-jump/pause/meanwhile" {
+ if child.value.relative {
+ // Snap past any fn-wrapper range before applying the relative jump
+ repetitions = calc.max(repetitions, last-subslide) + child.value.n
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ } else {
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = child.value.n
+ last-subslide = 0
+ }
+ } else if k == "touying-waypoint" {
+ if child.value.at("advance", default: true) {
+ repetitions = calc.max(repetitions + 1, last-subslide + 1)
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ } else if k == "touying-implicit-waypoint" {
+ repetitions = calc.max(repetitions + 1, last-subslide + 1)
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ } else if k == "touying-fn-wrapper" {
+ let ls = child.value.at("last-subslide", default: none)
+ if ls != none {
+ if type(ls) == function {
+ let (callback-ls, _) = ls(repetitions)
+ last-subslide = calc.max(last-subslide, callback-ls)
+ } else if type(ls) == int {
+ last-subslide = calc.max(last-subslide, ls)
+ }
+ }
+ }
+ }
+ }
+ return calc.max(max-repetitions, repetitions, last-subslide)
+ }
+
+ // Text-based blocks: equation, mitex, raw
+ let body = value.body
+ if type(body) == function {
+ // Cannot evaluate callback bodies during pre-pass (no self context).
+ return base
+ }
+ if type(body) != str {
+ return base
+ }
+
+ if kind == "touying-equation" {
+ let parts = body
+ .split(regex("(#meanwhile;?)|(meanwhile)"))
+ .intersperse("touying-meanwhile")
+ .map(s => s
+ .split(regex("(#pause;?)|(pause)"))
+ .intersperse("touying-pause"))
+ .flatten()
+ for part in parts {
+ if part == "touying-pause" {
+ repetitions += 1
+ } else if part == "touying-meanwhile" {
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = 1
+ }
+ }
+ } else if kind == "touying-mitex" {
+ let parts = body
+ .split(regex("\\\\meanwhile"))
+ .intersperse("touying-meanwhile")
+ .map(s => s.split(regex("\\\\pause")).intersperse("touying-pause"))
+ .flatten()
+ for part in parts {
+ if part == "touying-pause" {
+ repetitions += 1
+ } else if part == "touying-meanwhile" {
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = 1
+ }
+ }
+ } else if kind == "touying-raw" {
+ if value.at("simple", default: false) {
+ let parts = body
+ .split(regex("#meanwhile;"))
+ .intersperse("touying-meanwhile")
+ .map(s => s.split(regex("#pause;")).intersperse("touying-pause"))
+ .flatten()
+ for part in parts {
+ if part == "touying-pause" {
+ repetitions += 1
+ } else if part == "touying-meanwhile" {
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = 1
+ }
+ }
+ } else {
+ let meaningful-chars-pattern = regex("[a-zA-Z0-9\u{4E00}-\u{9FFF}]+")
+ for line in body.split("\n") {
+ let meaningful = line
+ .matches(meaningful-chars-pattern)
+ .map(m => m.text)
+ .join("")
+ if meaningful == "pause" {
+ repetitions += 1
+ } else if meaningful == "meanwhile" {
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = 1
+ }
+ }
+ }
+ }
+ calc.max(max-repetitions, repetitions)
+}
+
+/// Walk content children to collect waypoint declarations and track pause
+/// positions. Returns `(repetitions, last-subslide, waypoints-dict, start-overrides, decl-reps)`
+/// where `waypoints-dict` maps label strings to their raw subslide numbers,
+/// `start-overrides` maps labels with explicit `start` to their start spec
+/// (an int or a label string), and `decl-reps` maps labels to the effective
+/// repetitions counter at the point of declaration in the content
+/// (`calc.max(repetitions, last-subslide)`).
+///
+/// This mirrors the pause-tracking logic of `_parse-content-into-results-and-repetitions`
+/// but does NOT handle covering or visibility — it is a lightweight pre-pass.
+#let _collect-waypoints-impl(
+ children,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+) = {
+ // Helper: register a new advancing waypoint at the correct position.
+ // Uses max(repetitions+1, last-subslide+1) so that waypoints placed after a
+ // multi-subslide fn-wrapper (e.g. item-by-item) land AFTER its full range,
+ // not just one step past the last sequential pause.
+ let register-advancing-wp(
+ lbl,
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ ) = {
+ decl-reps.insert(lbl, calc.max(repetitions, last-subslide))
+ let pos = calc.max(repetitions + 1, last-subslide + 1)
+ repetitions = pos
+ last-subslide = calc.max(last-subslide, pos)
+ waypoints.insert(lbl, pos)
+ (repetitions, last-subslide, waypoints, decl-reps)
+ }
+
+ // Helper: register a waypoint that has an explicit `start` parameter.
+ // For int starts, applies jump effect immediately. For label refs, records
+ // a placeholder position (resolved later by _resolve-waypoint-forest).
+ let register-start-wp(
+ lbl,
+ wp-start,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = {
+ decl-reps.insert(lbl, calc.max(repetitions, last-subslide))
+ start-overrides.insert(lbl, wp-start)
+ if type(wp-start) == int {
+ waypoints.insert(lbl, wp-start)
+ repetitions = wp-start
+ last-subslide = calc.max(last-subslide, wp-start)
+ } else {
+ // Label reference (string) — can't resolve yet, use placeholder
+ waypoints.insert(lbl, repetitions)
+ }
+ (repetitions, last-subslide, waypoints, start-overrides, decl-reps)
+ }
+
+ for child in children {
+ if utils.is-sequence(child) {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = _collect-waypoints-impl(
+ child.children,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ } else if (
+ type(child) == content
+ and child.func() == metadata
+ and type(child.value) == dictionary
+ ) {
+ let kind = child.value.at("kind", default: none)
+ if kind == "touying-jump/pause/meanwhile" {
+ if child.value.relative {
+ // Snap past any preceding fn-wrapper range before applying the
+ // relative jump, so pauses after e.g. item-by-item land correctly.
+ repetitions = calc.max(repetitions, last-subslide) + child.value.n
+ } else {
+ repetitions = child.value.n
+ last-subslide = 0
+ }
+ } else if kind == "touying-waypoint" {
+ if not _waypoint-known(waypoints, child.value.label) {
+ let wp-start = child.value.at("start", default: auto)
+ if wp-start != auto {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = register-start-wp(
+ child.value.label,
+ wp-start,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ } else if child.value.at("advance", default: true) {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ ) = register-advancing-wp(
+ child.value.label,
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ )
+ } else {
+ decl-reps.insert(child.value.label, calc.max(
+ repetitions,
+ last-subslide,
+ ))
+ waypoints.insert(child.value.label, repetitions)
+ }
+ }
+ } else if kind == "touying-implicit-waypoint" {
+ if not _waypoint-known(waypoints, child.value.label) {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ ) = register-advancing-wp(
+ child.value.label,
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ )
+ }
+ } else if kind == "touying-set-config" {
+ let inner = if utils.is-sequence(child.value.body) {
+ child.value.body.children
+ } else {
+ (child.value.body,)
+ }
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = _collect-waypoints-impl(
+ inner,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ } else if kind in ("touying-equation", "touying-mitex", "touying-raw") {
+ repetitions = _count-animated-block-repetitions(
+ kind,
+ child.value,
+ repetitions,
+ )
+ } else if kind == "touying-reducer" {
+ // Recurse into the reducer's positional args to find waypoints and track pauses.
+ let inner-rep = repetitions
+ let inner-max = repetitions
+ let inner-ls = last-subslide
+ let inner-flat-args = ()
+ for arg in child.value.args.flatten() {
+ if type(arg) == content and utils.is-sequence(arg) {
+ inner-flat-args += arg.children
+ } else {
+ inner-flat-args.push(arg)
+ }
+ }
+ for inner-child in inner-flat-args {
+ if (
+ type(inner-child) == content
+ and inner-child.func() == metadata
+ and type(inner-child.value) == dictionary
+ ) {
+ let ik = inner-child.value.at("kind", default: none)
+ if ik == "touying-jump/pause/meanwhile" {
+ if inner-child.value.relative {
+ // Snap past any fn-wrapper range before applying the relative jump
+ inner-rep = calc.max(inner-rep, inner-ls) + inner-child.value.n
+ inner-max = calc.max(inner-max, inner-rep)
+ } else {
+ inner-max = calc.max(inner-max, inner-rep)
+ inner-rep = inner-child.value.n
+ inner-ls = 0
+ }
+ } else if ik == "touying-waypoint" {
+ if not _waypoint-known(waypoints, inner-child.value.label) {
+ let wp-start = inner-child.value.at("start", default: auto)
+ if wp-start != auto {
+ (
+ inner-rep,
+ inner-ls,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = register-start-wp(
+ inner-child.value.label,
+ wp-start,
+ inner-rep,
+ inner-ls,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ } else if inner-child.value.at("advance", default: true) {
+ (
+ inner-rep,
+ inner-ls,
+ waypoints,
+ decl-reps,
+ ) = register-advancing-wp(
+ inner-child.value.label,
+ inner-rep,
+ inner-ls,
+ waypoints,
+ decl-reps,
+ )
+ } else {
+ decl-reps.insert(inner-child.value.label, calc.max(
+ inner-rep,
+ inner-ls,
+ ))
+ waypoints.insert(inner-child.value.label, inner-rep)
+ }
+ }
+ } else if ik == "touying-implicit-waypoint" {
+ if not _waypoint-known(waypoints, inner-child.value.label) {
+ (
+ inner-rep,
+ inner-ls,
+ waypoints,
+ decl-reps,
+ ) = register-advancing-wp(
+ inner-child.value.label,
+ inner-rep,
+ inner-ls,
+ waypoints,
+ decl-reps,
+ )
+ }
+ } else if ik == "touying-fn-wrapper" {
+ // fn-wrappers can span multiple subslides via their last-subslide field.
+ let ls = inner-child.value.at("last-subslide", default: none)
+ if ls != none {
+ if type(ls) == function {
+ let (callback-ls, _) = ls(inner-rep)
+ inner-ls = calc.max(inner-ls, callback-ls)
+ } else if type(ls) == int {
+ inner-ls = calc.max(inner-ls, ls)
+ }
+ }
+ }
+ }
+ }
+ repetitions = calc.max(inner-max, inner-rep)
+ last-subslide = calc.max(last-subslide, inner-ls)
+ } else if kind == "touying-fn-wrapper" {
+ // fn-wrappers can span multiple subslides via their last-subslide field.
+ // Update last-subslide so that subsequent waypoints are placed AFTER
+ // this fn-wrapper's full animation range, not just at repetitions+1.
+ let ls = child.value.at("last-subslide", default: none)
+ if ls != none {
+ if type(ls) == function {
+ let (callback-ls, _) = ls(repetitions)
+ last-subslide = calc.max(last-subslide, callback-ls)
+ } else if type(ls) == int {
+ last-subslide = calc.max(last-subslide, ls)
+ }
+ }
+ }
+ } else if utils.is-styled(child) {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = _collect-waypoints-impl(
+ (child.child,),
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ } else if (
+ type(child) == content and child.func() in (table.cell, grid.cell)
+ ) {
+ // Handle table/grid cells that may wrap jump or waypoint metadata
+ if (
+ type(child.body) == content
+ and child.body.func() == metadata
+ and type(child.body.value) == dictionary
+ ) {
+ let kind = child.body.value.at("kind", default: none)
+ if kind == "touying-jump/pause/meanwhile" {
+ if child.body.value.relative {
+ repetitions = (
+ calc.max(repetitions, last-subslide) + child.body.value.n
+ )
+ } else {
+ repetitions = child.body.value.n
+ last-subslide = 0
+ }
+ } else if kind == "touying-waypoint" {
+ if not _waypoint-known(waypoints, child.body.value.label) {
+ let wp-start = child.body.value.at("start", default: auto)
+ if wp-start != auto {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = register-start-wp(
+ child.body.value.label,
+ wp-start,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ } else if child.body.value.at("advance", default: true) {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ ) = register-advancing-wp(
+ child.body.value.label,
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ )
+ } else {
+ decl-reps.insert(child.body.value.label, calc.max(
+ repetitions,
+ last-subslide,
+ ))
+ waypoints.insert(child.body.value.label, repetitions)
+ }
+ }
+ } else if kind == "touying-implicit-waypoint" {
+ if not _waypoint-known(waypoints, child.body.value.label) {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ ) = register-advancing-wp(
+ child.body.value.label,
+ repetitions,
+ last-subslide,
+ waypoints,
+ decl-reps,
+ )
+ }
+ }
+ } else {
+ // Cell body is not a direct metadata wrapper — recurse into it
+ // to find any embedded waypoints/pauses.
+ let body = child.at("body", default: none)
+ if body != none {
+ let inner = if utils.is-sequence(body) {
+ body.children
+ } else {
+ (body,)
+ }
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = _collect-waypoints-impl(
+ inner,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ }
+ }
+ } else if type(child) == content {
+ // Recurse into content with a body field
+ let body = child.at("body", default: none)
+ if body != none {
+ let inner = if utils.is-sequence(body) {
+ body.children
+ } else {
+ (body,)
+ }
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = _collect-waypoints-impl(
+ inner,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ }
+ // Recurse into children (table, grid, stack, etc.)
+ if child.has("children") {
+ let ch = child.at("children", default: none)
+ if ch != none and type(ch) == array {
+ (
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ ) = _collect-waypoints-impl(
+ ch,
+ repetitions,
+ last-subslide,
+ waypoints,
+ start-overrides,
+ decl-reps,
+ )
+ }
+ }
+ }
+ }
+ (repetitions, last-subslide, waypoints, start-overrides, decl-reps)
+}
+
+
+/// Collect all waypoint labels from slide bodies.
+///
+/// Returns a pair `(raw-waypoints, start-overrides)` where `raw-waypoints`
+/// maps label strings to their raw subslide numbers and `start-overrides`
+/// maps labels with explicit `start` to their start spec (int or label string).
+///
+/// - bodies (content): The content bodies to scan.
+///
+/// -> (dictionary, dictionary)
+#let _collect-waypoints(..bodies) = {
+ let (_, _, waypoints, start-overrides, decl-reps) = _collect-waypoints-impl(
+ bodies.pos(),
+ 1,
+ 0,
+ (:),
+ (:),
+ (:),
+ )
+ (waypoints, start-overrides, decl-reps)
+}
+
+
+/// Resolve explicit waypoint `start` overrides.
+///
+/// Builds a dependency forest from waypoints with `start` parameters and
+/// resolves them in topological order (roots first). Waypoints with
+/// `start: int` are resolved directly. Waypoints with `start: <label>`
+/// inherit the position of the referenced waypoint.
+/// Calling a non-existant parent of a hierarchical label will yield the position of the first child.
+///
+/// Panics on circular dependencies.
+///
+/// - raw-waypoints (dictionary): Map of label → raw subslide number.
+///
+/// - start-overrides (dictionary): Map of label → start spec (int or string).
+///
+/// -> dictionary
+#let _resolve-waypoint-forest(raw-waypoints, start-overrides) = {
+ if start-overrides.len() == 0 {
+ return raw-waypoints
+ }
+
+ // Phase 1: Apply int overrides directly
+ for (lbl, start) in start-overrides.pairs() {
+ if type(start) == int {
+ raw-waypoints.insert(lbl, start)
+ }
+ }
+
+ // Phase 2: Iteratively resolve label references
+ // Each iteration resolves waypoints whose dependency is already resolved.
+ // If no progress is made, a cycle exists.
+ let pending = (:)
+ for (lbl, start) in start-overrides.pairs() {
+ if type(start) == str {
+ pending.insert(lbl, start)
+ }
+ }
+ //returns true if parent is an ancestor of child, i.e. if child starts with parent + ":"
+ let _check_parent_label(parent, child) = {
+ let prefix = parent + ":"
+ return child.starts-with(prefix)
+ }
+
+ let max-iterations = pending.len() + 1
+ let iteration = 0
+ while pending.len() > 0 {
+ iteration += 1
+ if iteration > max-iterations {
+ panic(
+ "Circular waypoint dependency detected among: "
+ + pending.keys().join(", "),
+ )
+ }
+ let still-pending = (:)
+ for (lbl, ref) in pending.pairs() {
+ let resolved-child = raw-waypoints
+ .keys()
+ .sorted(key: k => raw-waypoints.at(k))
+ .find(child => _check_parent_label(ref, child))
+ if ref not in pending or resolved-child != none {
+ // The referenced waypoint, or a child is already resolved
+ assert(
+ ref in raw-waypoints or resolved-child != none,
+ message: "waypoint start: references unknown waypoint <" + ref + ">",
+ )
+ if resolved-child != none {
+ ref = resolved-child
+ }
+ raw-waypoints.insert(lbl, raw-waypoints.at(ref))
+ } else {
+ still-pending.insert(lbl, ref)
+ }
+ }
+ pending = still-pending
+ }
+
+ raw-waypoints
+}
+
+
+/// Compute waypoint ranges from raw waypoint positions.
+///
+/// Each waypoint covers subslides from its declared position until the next
+/// waypoint starts (or the end of the slide for the last one).
+///
+/// When a waypoint uses `start` to jump backward, its predecessor's range
+/// extends through the declaration point (the subslide the content was at
+/// before the jump), allowing overlapping ranges.
+///
+/// - raw-waypoints (dictionary): Map of label → subslide number.
+///
+/// - total-repeat (int): Total number of subslides in the slide.
+///
+/// - start-overrides (dictionary): Map of label → start spec for explicit starts.
+///
+/// - decl-reps (dictionary): Map of label → effective repetitions at declaration.
+///
+/// -> dictionary
+#let _compute-waypoint-ranges(
+ raw-waypoints,
+ total-repeat,
+ start-overrides,
+ decl-reps,
+) = {
+ if raw-waypoints.len() == 0 {
+ return (:)
+ }
+ // Use content declaration order (dictionary insertion order) — a waypoint
+ // captures all subslides until the next waypoint is declared in content,
+ // regardless of subslide positions.
+ let content-order = raw-waypoints.pairs()
+ let result = (:)
+ for (i, (lbl, first)) in content-order.enumerate() {
+ let last = if i + 1 < content-order.len() {
+ let (next-lbl, _) = content-order.at(i + 1)
+ decl-reps.at(next-lbl, default: first)
+ } else {
+ total-repeat
+ }
+ // Ensure last >= first (ranges can overlap when explicit start jumps backward)
+ let last = calc.max(first, last)
+ result.insert(lbl, (first: first, last: last))
+ }
+ result
+}
+
+
+///
+/// This is the core parsing function that handles all types of content including
+/// animations, pauses, meanwhile markers, and various content types. It recursively
+/// processes content and determines what should be visible on each subslide.
+///
+/// - self (dictionary): The presentation context
+/// - need-cover (bool): Whether hidden content should be covered
+/// - base (int): Base repetition count
+/// - index (int): Current subslide index
+/// - show-delayed-wrapper (bool): Whether to show delayed wrapper content
+/// - bodies (content): The content elements to parse
+///
+/// -> (array, int, int, int)
+#let _parse-content-into-results-and-repetitions(
+ self: none,
+ need-cover: true,
+ base: 1,
+ base-last-subslide: 0,
+ index: 1,
+ show-delayed-wrapper: false,
+ ..bodies,
+) = {
+ let labeled(func) = {
+ return not (
+ "repeat" in self
+ and "subslide" in self
+ and "label-only-on-last-subslide" in self
+ and func in self.label-only-on-last-subslide
+ and self.subslide != self.repeat
+ )
+ }
+ // Helper function to parse child content and reconstruct
+ // Returns a 5-tuple:
+ // - reconstructed-content: the reconstructed container content
+ // - max-repetitions: maximum repetitions found inside the content
+ // - next-last-subslide: maximum last-subslide of any fn-wrappers found (0 if none)
+ // - final-repetitions: repetitions count after processing all inner content
+ // - force-to-result: true when fn-wrappers were found inside a pause zone and the
+ // returned `reconstructed-content` was produced with proper inner covering;
+ // the caller MUST push this content directly to `result` (not `hidden-parts`).
+ let parse-and-reconstruct(
+ self,
+ child,
+ body-field,
+ repetitions,
+ last-subslide,
+ index,
+ need-cover,
+ reconstruct-fn,
+ ) = {
+ let body-content = if body-field == "body-or-none" {
+ child.at("body", default: none)
+ } else {
+ child.at(body-field)
+ }
+ let (
+ conts,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ inner-has-fn-wrapper,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ body-content,
+ )
+ let cont = conts.first()
+ // Two-pass: if fn-wrappers are present inside a pause zone, re-run the inner parse
+ // with the outer need-cover so that fn-wrappers handle their own visibility and
+ // non-fn-wrapper content is properly covered by the inner mechanism.
+ let would-be-hidden = not (
+ calc.min(repetitions, final-repetitions) <= index or not need-cover
+ )
+ if would-be-hidden and inner-has-fn-wrapper {
+ let (
+ conts2,
+ inner-max-repetitions2,
+ _,
+ _,
+ _,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: need-cover,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ body-content,
+ )
+ let cont2 = conts2.first()
+ return (
+ reconstruct-fn(child, cont2),
+ inner-max-repetitions2,
+ next-last-subslide,
+ final-repetitions,
+ true,
+ )
+ }
+ return (
+ reconstruct-fn(child, cont),
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ false,
+ )
+ }
+ // Content function sets for different handling categories
+ let list-item-functions = (list.item, enum.item, align, link)
+ let table-like-functions = (table, grid, stack)
+ let reconstructable-functions = (
+ pad,
+ figure,
+ quote,
+ strong,
+ emph,
+ footnote,
+ highlight,
+ overline,
+ underline,
+ strike,
+ smallcaps,
+ sub,
+ super,
+ box,
+ block,
+ hide,
+ move,
+ scale,
+ circle,
+ ellipse,
+ rect,
+ square,
+ table.cell,
+ grid.cell,
+ math.equation,
+ heading,
+ )
+ let bodies = bodies.pos()
+ let parsed-results = ()
+ // repetitions
+ let repetitions = base
+ let max-repetitions = repetitions
+ // last-subslide by touying-fn-wrapper — inherit outer context so waypoints
+ // placed after multi-subslide fn-wrappers fire correctly inside sub-sequences.
+ let last-subslide = base-last-subslide
+ // Whether any touying-fn-wrapper was found in this parse (directly or via
+ // recursive calls). Used by the two-pass escape hatch so that fn-wrappers
+ // inside a pause zone can handle their own visibility.
+ let has-fn-wrapper = false
+ // get cover function from self
+ let cover = self.methods.cover.with(self: self)
+
+ // Main parsing loop: process each content item and handle animations
+ for item in bodies {
+ let it = item
+ // Special handling for table/grid cells containing pause/meanwhile/waypoint markers
+ // This is a workaround for syntax like #table([A], pause, [B])
+ // Waypoints and implicit waypoints are also stripped so they don't occupy a cell slot.
+ if type(it) == content and it.func() in (table.cell, grid.cell) {
+ if (
+ type(it.body) == content
+ and it.body.func() == metadata
+ and type(it.body.value) == dictionary
+ ) {
+ let kind = it.body.value.at("kind", default: none)
+ if kind == "touying-jump/pause/meanwhile" {
+ if it.body.value.relative {
+ repetitions = calc.max(repetitions, last-subslide) + it.body.value.n
+ } else {
+ // absolute jump
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = it.body.value.n
+ last-subslide = 0
+ }
+ continue
+ } else if kind == "touying-waypoint" {
+ let wp = self.at("waypoints", default: (:))
+ let lbl = it.body.value.label
+ let wp-start = it.body.value.at("start", default: auto)
+ if wp-start != auto and lbl in wp {
+ // Explicit start: absolute jump to the resolved position.
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = wp.at(lbl).first
+ last-subslide = 0
+ } else if it.body.value.at("advance", default: true) and lbl in wp {
+ let first = wp.at(lbl).first
+ if (
+ first == repetitions + 1
+ or (first == last-subslide + 1 and first > repetitions)
+ ) {
+ repetitions = first
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ }
+ continue
+ } else if kind == "touying-implicit-waypoint" {
+ let wp = self.at("waypoints", default: (:))
+ let lbl = it.body.value.label
+ if lbl in wp {
+ let first = wp.at(lbl).first
+ if (
+ first == repetitions + 1
+ or (first == last-subslide + 1 and first > repetitions)
+ ) {
+ repetitions = first
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ }
+ continue
+ }
+ }
+ }
+ // if it is a function, then call it with self
+ if type(it) == function {
+ // subslide index
+ it = it(self)
+ }
+ // parse the content
+ let result = ()
+ let hidden-parts = ()
+
+ // Helper: is this content element a list/enum/terms item?
+ let _is-list-item(it) = (
+ type(it) == content
+ and (
+ it.func() == list.item
+ or it.func() == enum.item
+ or it.func() == terms.item
+ )
+ )
+
+ /// Flush the hidden-parts buffer as covered content. `last-result` is the
+ /// current visible result array at the flush point. We only wrap in
+ /// `block(spacing: par.leading)` when the last visible element AND the first
+ /// hidden element are both list/enum/terms items — i.e. a list interrupted
+ /// by `#pause`. In all other cases (text→list, list→text, text→text) the
+ /// default paragraph spacing is correct.
+ let cover-hidden(cover-fn, items, last-result) = {
+ // First non-space hidden element
+ let first-pos = items.position(item => not utils.is-space(item))
+ let first-is-list = (
+ first-pos != none and _is-list-item(items.at(first-pos))
+ )
+
+ // Last non-space visible element (walk result backwards).
+ // We only skip space nodes — parbreaks and linebreaks are meaningful
+ // separators. A parbreak between the last visible list item and the
+ // hidden zone means the user broke the implicit list with a blank line,
+ // so paragraph spacing should be used instead of list spacing.
+ let last-is-list = {
+ let found = false
+ for i in range(last-result.len()) {
+ let item = last-result.at(last-result.len() - 1 - i)
+ if utils.is-space(item) {
+ // skip space nodes only
+ } else {
+ found = _is-list-item(item)
+ break
+ }
+ }
+ found
+ }
+ let spacing-is-auto(it) = {
+ if it.func() == list.item {
+ list.spacing == auto
+ } else if it.func() == enum.item {
+ enum.spacing == auto
+ } else if it.func() == terms.item {
+ terms.spacing == auto
+ } else {
+ false
+ }
+ }
+ let covered = cover-fn(items.sum())
+ //decrease below spacing for rect cover functions
+ // if type(cover-fn) == function and (
+ // cover-fn==utils.cover-with-rect or
+ // cover-fn==utils.semi-transparent-cover
+ // ){
+ // covered // does not fix it, but does not hurt: problem stems from box itself causing later content to be shifted? idk
+ // }else
+ if first-is-list and last-is-list {
+ let first-item = items.at(first-pos)
+ // construct a block around the covered content that corrects spacing. looks for auto
+ context block(
+ spacing: if spacing-is-auto(first-item) {
+ // would yield `auto` which is a par.spacing for the block.
+ if self.at("nontight-list-enum-and-terms", default: true) {
+ //cannot set list thightness via set rule somehow. if user uses magic.nontight locally we can't detect that, so we just assume he only uses the config. thus this might break.
+ par.spacing
+ } else {
+ par.leading
+ }
+ } else {
+ if first-item.func() == list.item {
+ list.spacing
+ } else if first-item.func() == enum.item {
+ enum.spacing
+ } else if first-item.func() == terms.item {
+ terms.spacing
+ } else {
+ par.spacing
+ }
+ },
+ covered,
+ )
+ } else {
+ covered
+ }
+ }
+
+ // Flatten sequences and handle each child element
+ let children = if utils.is-sequence(it) {
+ it.children
+ } else {
+ (it,)
+ }
+
+ // Process each child element for animation markers and content types
+ for child in children {
+ if (
+ type(child) == content
+ and child.func() == metadata
+ and type(child.value) == dictionary
+ ) {
+ let kind = child.value.at("kind", default: none)
+ if kind == "touying-jump/pause/meanwhile" {
+ if child.value.relative {
+ // Snap past any preceding fn-wrapper range before applying the
+ // relative jump, so that a #pause after e.g. item-by-item lands
+ // after the full animation, not after its first subslide.
+ repetitions = calc.max(repetitions, last-subslide) + child.value.n
+ // Track the peak repetitions so that a subsequent negative jump doesn't
+ // cause the slide count to be underestimated
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ // If we jumped back into the visible zone, flush hidden-parts in order
+ // (so they appear before subsequent visible content, not after it)
+ if hidden-parts.len() != 0 and repetitions <= index {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ } else {
+ // absolute: reveal all hidden content then jump to target subslide
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = child.value.n
+ last-subslide = 0
+ }
+ } else if kind == "touying-equation" {
+ // Handle animated equations with pause/meanwhile markers
+ let (conts, nextrepetitions) = _parse-touying-equation(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ index: index,
+ child,
+ )
+ let cont = conts.first()
+ if repetitions <= index or not need-cover {
+ result.push(cont)
+ } else {
+ hidden-parts.push(cont)
+ }
+ repetitions = nextrepetitions
+ } else if kind == "touying-mitex" {
+ // Handle animated MiTeX equations with pause/meanwhile markers
+ let (conts, nextrepetitions) = _parse-touying-mitex(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ index: index,
+ child,
+ )
+ let cont = conts.first()
+ if repetitions <= index or not need-cover {
+ result.push(cont)
+ } else {
+ hidden-parts.push(cont)
+ }
+ repetitions = nextrepetitions
+ } else if kind == "touying-raw" {
+ // Handle animated raw code blocks with pause/meanwhile markers
+ let (conts, nextrepetitions) = _parse-touying-raw(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ index: index,
+ child,
+ )
+ let cont = conts.first()
+ if repetitions <= index or not need-cover {
+ result.push(cont)
+ } else {
+ hidden-parts.push(cont)
+ }
+ repetitions = nextrepetitions
+ } else if kind == "touying-reducer" {
+ // Handle external package reducers (CeTZ, Fletcher) with animations
+ let (conts, nextrepetitions) = _parse-touying-reducer(
+ self: self,
+ base: repetitions,
+ index: index,
+ child.value,
+ )
+ let cont = conts.first()
+ if repetitions <= index or not need-cover {
+ result.push(cont)
+ } else {
+ hidden-parts.push(cont)
+ }
+ repetitions = nextrepetitions
+ } else if kind == "touying-fn-wrapper" {
+ // Handle function wrappers (uncover, only, alternatives, etc.)
+ // These always escape the pause zone: they handle their own subslide
+ // visibility internally, so they must never be pushed to hidden-parts.
+ has-fn-wrapper = true
+ let nextrepetitions = repetitions
+ let extra-args = (:)
+ if child.value.last-subslide != none {
+ if type(child.value.last-subslide) == function {
+ let (callback-last-subslide, callback-extra-args) = (
+ child.value.last-subslide
+ )(
+ repetitions,
+ )
+ // Use calc.max to prevent callback from decreasing last-subslide
+ // (mirrors the non-callback else-branch)
+ last-subslide = calc.max(last-subslide, callback-last-subslide)
+ extra-args = callback-extra-args
+ } else {
+ last-subslide = calc.max(last-subslide, child.value.last-subslide)
+ }
+ }
+ //check child.value.args for touying-fn-wrapper-raw. may only be in content, which always is positional
+ let pos-args = child
+ .value
+ .args
+ .pos()
+ .map(c => {
+ if (
+ type(c) == content
+ and c.func() == metadata
+ and type(c.value) == dictionary
+ and c.value.at("kind", default: none)
+ == "touying-fn-wrapper-raw"
+ ) {
+ (c.value.fn)(
+ self: self,
+ ..c.value.args,
+ )
+ } else {
+ c
+ }
+ })
+
+ result.push((child.value.fn)(
+ self: self,
+ ..pos-args,
+ ..child.value.args.named(),
+ ..extra-args,
+ ))
+ repetitions = nextrepetitions
+ } else if kind == "touying-fn-wrapper-raw" {
+ // Handle raw function wrappers (e.g., #alert)
+ if repetitions <= index or not need-cover {
+ result.push((child.value.fn)(
+ self: self,
+ ..child.value.args,
+ ))
+ } else {
+ hidden-parts.push((child.value.fn)(
+ self: self,
+ ..child.value.args,
+ ))
+ }
+ } else if kind == "touying-speaker-note" {
+ // Handle speaker notes with optional #pause markers inside the note body.
+ // Speaker notes always escape the pause zone (like fn-wrappers): they emit
+ // only side effects (state updates, pdfpc metadata) and produce no visible content.
+ let outer-rep = repetitions // pause count at this position in the outer slide
+
+ // Inner subslide index: how far into the note's own pauses we advance.
+ // If the outer slide is at repetition outer-rep and we're rendering subslide index,
+ // the note's inner subslide is (index - outer-rep + 1), clamped to >= 1.
+ let inner-index = calc.max(1, index - outer-rep + 1)
+
+ // Use _parse-content-into-results-and-repetitions to handle nested pauses
+ // (e.g. #pause inside a list item). Override cover to omit hidden content
+ // entirely (notes don't need visual placeholders for covered text).
+ let note-self = utils.merge-dicts(
+ self,
+ (methods: (cover: (self: none, body) => [])),
+ )
+ let (
+ note-conts,
+ note-max-rep,
+ _,
+ _,
+ _,
+ ) = _parse-content-into-results-and-repetitions(
+ self: note-self,
+ need-cover: true,
+ base: 1,
+ index: inner-index,
+ child.value.note,
+ )
+ let note-cont = note-conts.first()
+
+ // Account for subslides needed by inner pauses in the note body.
+ max-repetitions = calc.max(
+ max-repetitions,
+ outer-rep + note-max-rep - 1,
+ )
+
+ // Determine the effective outer subslide filter.
+ let effective-subslide = if child.value.subslide == auto {
+ str(outer-rep) + "-"
+ } else {
+ child.value.subslide
+ }
+
+ // Always push to result (never hidden-parts): produces no visible content.
+ result.push(utils.speaker-note(
+ self: self,
+ mode: child.value.mode,
+ setting: child.value.setting,
+ subslide: effective-subslide,
+ note-cont,
+ ))
+ } else if kind == "touying-waypoint" {
+ let wp = self.at("waypoints", default: (:))
+ let lbl = child.value.label
+ let wp-start = child.value.at("start", default: auto)
+ if wp-start != auto and lbl in wp {
+ // Explicit start: absolute jump to the resolved position.
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ repetitions = wp.at(lbl).first
+ last-subslide = 0
+ } else if child.value.at("advance", default: true) and lbl in wp {
+ let first = wp.at(lbl).first
+ if (
+ first == repetitions + 1
+ or (first == last-subslide + 1 and first > repetitions)
+ ) {
+ repetitions = first
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ }
+ // No visible output.
+ } else if kind == "touying-implicit-waypoint" {
+ // Implicit waypoint: advance repetitions if this is the defining occurrence.
+ // Fires on the standard sequential trigger (first == repetitions+1) OR
+ // when a preceding fn-wrapper pushed last-subslide forward and this
+ // waypoint sits immediately after it (first == last-subslide+1).
+ let wp = self.at("waypoints", default: (:))
+ let lbl = child.value.label
+ if lbl in wp {
+ let first = wp.at(lbl).first
+ if (
+ first == repetitions + 1
+ or (first == last-subslide + 1 and first > repetitions)
+ ) {
+ repetitions = first
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ }
+ // No visible output.
+ } else if kind == "touying-delayed-wrapper" {
+ if show-delayed-wrapper {
+ if repetitions <= index or not need-cover {
+ result.push(child.value.body)
+ } else {
+ hidden-parts.push(child.value.body)
+ }
+ }
+ } else {
+ if repetitions <= index or not need-cover {
+ result.push(child)
+ } else {
+ hidden-parts.push(child)
+ }
+ }
+ } else if child == linebreak() or child == parbreak() {
+ // clear the hidden-parts when encounter linebreak or parbreak
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ result.push(child)
+ } else if utils.is-sequence(child) {
+ // handle the sequence
+ let (
+ conts,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ inner-has-fn-wrapper,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ child,
+ )
+ has-fn-wrapper = has-fn-wrapper or inner-has-fn-wrapper
+ // Two-pass: if fn-wrappers are present and sequence would be hidden,
+ // re-run with outer need-cover so fn-wrappers handle their own visibility.
+ let would-be-hidden = not (
+ calc.min(repetitions, final-repetitions) <= index or not need-cover
+ )
+ let (cont, inner-max-repetitions) = if (
+ would-be-hidden and inner-has-fn-wrapper
+ ) {
+ let (
+ conts2,
+ inner-max-repetitions2,
+ _,
+ _,
+ _,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: need-cover,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ child,
+ )
+ (conts2.first(), inner-max-repetitions2)
+ } else {
+ (conts.first(), inner-max-repetitions)
+ }
+ // Propagate meanwhile effect from inside the sequence
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ if (
+ would-be-hidden and inner-has-fn-wrapper
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(cont)
+ } else {
+ hidden-parts.push(cont)
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ } else if utils.is-styled(child) {
+ // handle styled
+ let (
+ reconstructed,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ force-to-result,
+ ) = parse-and-reconstruct(
+ self,
+ child,
+ "child",
+ repetitions,
+ last-subslide,
+ index,
+ need-cover,
+ (child, cont) => utils.typst-builtin-styled(cont, child.styles),
+ )
+ // Propagate meanwhile effect from inside the styled element
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ if (
+ force-to-result
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(reconstructed)
+ } else {
+ hidden-parts.push(reconstructed)
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ has-fn-wrapper = has-fn-wrapper or force-to-result
+ } else if (
+ type(child) == content and child.func() in list-item-functions
+ ) {
+ // handle the list item
+ let (
+ reconstructed,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ force-to-result,
+ ) = parse-and-reconstruct(
+ self,
+ child,
+ "body",
+ repetitions,
+ last-subslide,
+ index,
+ need-cover,
+ (child, cont) => utils.reconstruct(
+ child,
+ labeled: labeled(child.func()),
+ cont,
+ ),
+ )
+ // Propagate meanwhile effect from inside the list item
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ if (
+ force-to-result
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(reconstructed)
+ } else {
+ hidden-parts.push(reconstructed)
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ has-fn-wrapper = has-fn-wrapper or force-to-result
+ } else if (
+ type(child) == content and child.func() in table-like-functions
+ ) {
+ // handle the table-like
+ let (
+ conts,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ inner-has-fn-wrapper,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ ..child.children,
+ )
+ has-fn-wrapper = has-fn-wrapper or inner-has-fn-wrapper
+ // Two-pass: if fn-wrappers are present and container would be hidden,
+ // re-run with outer need-cover so fn-wrappers handle their own visibility.
+ let would-be-hidden = not (
+ calc.min(repetitions, final-repetitions) <= index or not need-cover
+ )
+ let (conts, inner-max-repetitions) = if (
+ would-be-hidden and inner-has-fn-wrapper
+ ) {
+ let (
+ conts2,
+ inner-max-repetitions2,
+ _,
+ _,
+ _,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: need-cover,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ ..child.children,
+ )
+ (conts2, inner-max-repetitions2)
+ } else {
+ (conts, inner-max-repetitions)
+ }
+ // Propagate meanwhile effect from inside the table/grid/stack
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ let reconstructed-table = utils.reconstruct-table-like(
+ child,
+ labeled: labeled(child.func()),
+ conts,
+ )
+ if (
+ would-be-hidden and inner-has-fn-wrapper
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(reconstructed-table)
+ } else {
+ hidden-parts.push(reconstructed-table)
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ } else if (
+ type(child) == content and child.func() in reconstructable-functions
+ ) {
+ let (
+ reconstructed,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ force-to-result,
+ ) = parse-and-reconstruct(
+ self,
+ child,
+ "body-or-none",
+ repetitions,
+ last-subslide,
+ index,
+ need-cover,
+ (child, cont) => utils.reconstruct(
+ named: true,
+ labeled: labeled(child.func()),
+ child,
+ cont,
+ ),
+ )
+ // Propagate meanwhile effect from inside the reconstructable element
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ if (
+ force-to-result
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(reconstructed)
+ } else {
+ hidden-parts.push(reconstructed)
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ has-fn-wrapper = has-fn-wrapper or force-to-result
+ } else if type(child) == content and child.func() == terms.item {
+ // handle the terms item
+ let (
+ reconstructed,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ force-to-result,
+ ) = parse-and-reconstruct(
+ self,
+ child,
+ "description",
+ repetitions,
+ last-subslide,
+ index,
+ need-cover,
+ (child, cont) => terms.item(child.term, cont),
+ )
+ // Propagate meanwhile effect from inside the terms item
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ if (
+ force-to-result
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(reconstructed)
+ } else {
+ hidden-parts.push(reconstructed)
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ has-fn-wrapper = has-fn-wrapper or force-to-result
+ } else if type(child) == content and child.func() == columns {
+ // handle columns
+ let (
+ conts,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ inner-has-fn-wrapper,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ child.body,
+ )
+ has-fn-wrapper = has-fn-wrapper or inner-has-fn-wrapper
+ let args = if child.has("gutter") {
+ (gutter: child.gutter)
+ }
+ let count = if child.has("count") {
+ child.count
+ } else {
+ 2
+ }
+ // Two-pass: if fn-wrappers are present and columns would be hidden,
+ // re-run with outer need-cover so fn-wrappers handle their own visibility.
+ let would-be-hidden = not (
+ calc.min(repetitions, final-repetitions) <= index or not need-cover
+ )
+ let (cont, inner-max-repetitions) = if (
+ would-be-hidden and inner-has-fn-wrapper
+ ) {
+ let (
+ conts2,
+ inner-max-repetitions2,
+ _,
+ _,
+ _,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: need-cover,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ child.body,
+ )
+ (conts2.first(), inner-max-repetitions2)
+ } else {
+ (conts.first(), inner-max-repetitions)
+ }
+ // Propagate meanwhile effect from inside the columns
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ if (
+ would-be-hidden and inner-has-fn-wrapper
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(columns(count, ..args, cont))
+ } else {
+ hidden-parts.push(columns(count, ..args, cont))
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ } else if type(child) == content and child.func() == place {
+ // handle place
+ let (
+ conts,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ inner-has-fn-wrapper,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ child.body,
+ )
+ has-fn-wrapper = has-fn-wrapper or inner-has-fn-wrapper
+ let fields = child.fields()
+ let _ = fields.remove("alignment", default: none)
+ let _ = fields.remove("body", default: none)
+ let alignment = if child.has("alignment") {
+ child.alignment
+ } else {
+ start
+ }
+ // Two-pass: if fn-wrappers are present and place would be hidden,
+ // re-run with outer need-cover so fn-wrappers handle their own visibility.
+ let would-be-hidden = not (
+ calc.min(repetitions, final-repetitions) <= index or not need-cover
+ )
+ let (cont, inner-max-repetitions) = if (
+ would-be-hidden and inner-has-fn-wrapper
+ ) {
+ let (
+ conts2,
+ inner-max-repetitions2,
+ _,
+ _,
+ _,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: need-cover,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ child.body,
+ )
+ (conts2.first(), inner-max-repetitions2)
+ } else {
+ (conts.first(), inner-max-repetitions)
+ }
+ // Propagate meanwhile effect from inside the place
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ if (
+ would-be-hidden and inner-has-fn-wrapper
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(place(alignment, ..fields, cont))
+ } else {
+ hidden-parts.push(place(alignment, ..fields, cont))
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ } else if type(child) == content and child.func() == rotate {
+ // handle rotate
+ let (
+ conts,
+ inner-max-repetitions,
+ next-last-subslide,
+ final-repetitions,
+ inner-has-fn-wrapper,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: repetitions <= index,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ child.body,
+ )
+ has-fn-wrapper = has-fn-wrapper or inner-has-fn-wrapper
+ let fields = child.fields()
+ let _ = fields.remove("angle", default: none)
+ let _ = fields.remove("body", default: none)
+ let angle = if child.has("angle") {
+ child.angle
+ } else {
+ 0deg
+ }
+ // Two-pass: if fn-wrappers are present and rotate would be hidden,
+ // re-run with outer need-cover so fn-wrappers handle their own visibility.
+ let would-be-hidden = not (
+ calc.min(repetitions, final-repetitions) <= index or not need-cover
+ )
+ let (cont, inner-max-repetitions) = if (
+ would-be-hidden and inner-has-fn-wrapper
+ ) {
+ let (
+ conts2,
+ inner-max-repetitions2,
+ _,
+ _,
+ _,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ need-cover: need-cover,
+ base: repetitions,
+ base-last-subslide: last-subslide,
+ index: index,
+ child.body,
+ )
+ (conts2.first(), inner-max-repetitions2)
+ } else {
+ (conts.first(), inner-max-repetitions)
+ }
+ // Propagate meanwhile effect from inside the rotate
+ if final-repetitions < repetitions {
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ }
+ if (
+ would-be-hidden and inner-has-fn-wrapper
+ or calc.min(repetitions, final-repetitions) <= index
+ or not need-cover
+ ) {
+ result.push(rotate(angle, ..fields, cont))
+ } else {
+ hidden-parts.push(rotate(angle, ..fields, cont))
+ }
+ repetitions = final-repetitions
+ max-repetitions = calc.max(max-repetitions, inner-max-repetitions)
+ last-subslide = calc.max(last-subslide, next-last-subslide)
+ } else {
+ if repetitions <= index or not need-cover {
+ result.push(child)
+ } else {
+ hidden-parts.push(child)
+ }
+ }
+ }
+ // clear the hidden-parts when end
+ if hidden-parts.len() != 0 {
+ result.push(cover-hidden(cover, hidden-parts, result))
+ hidden-parts = ()
+ }
+ parsed-results.push(result.sum(default: []))
+ }
+ max-repetitions = calc.max(max-repetitions, repetitions)
+ return (
+ parsed-results,
+ max-repetitions,
+ last-subslide,
+ repetitions,
+ has-fn-wrapper,
+ )
+}
+
+// get negative pad for header and footer
+#let _get-negative-pad(self) = {
+ let margin = self.page.margin
+ if (
+ type(margin) != dictionary
+ and type(margin) != length
+ and type(margin) != relative
+ and type(margin) != ratio
+ ) {
+ return it => it
+ }
+
+ let cell = block.with(
+ width: 100%,
+ height: 100%,
+ above: 0pt,
+ below: 0pt,
+ breakable: false,
+ )
+
+ return it => context {
+ let page-width = page.width
+ let to-abs(val) = {
+ if type(val) == ratio {
+ val * page-width
+ } else if type(val) == relative {
+ val.ratio * page-width + val.length
+ } else {
+ val
+ }
+ }
+
+ if type(margin) == length {
+ pad(x: -margin, cell(it))
+ } else if (
+ type(margin) == ratio or type(margin) == relative
+ ) {
+ pad(x: -to-abs(margin), cell(it))
+ } else {
+ let pad-args = (:)
+ if "x" in margin {
+ pad-args.x = -to-abs(margin.x)
+ }
+ if "left" in margin {
+ pad-args.left = -to-abs(margin.left)
+ }
+ if "right" in margin {
+ pad-args.right = -to-abs(margin.right)
+ }
+ if "rest" in margin {
+ pad-args.x = -to-abs(margin.rest)
+ }
+ pad(..pad-args, cell(it))
+ }
+ }
+}
+
+// get bottom pad for footer
+#let _get-bottom-pad(self) = {
+ let cell = block.with(
+ width: 100%,
+ height: 100%,
+ above: 0pt,
+ below: 0pt,
+ breakable: false,
+ )
+ let (_, page-height) = utils.get-page-dimensions(self)
+ it => pad(bottom: page-height, cell(it))
+}
+
+// Scale content down to new-height, preserving aspect ratio.
+// Returns a box of dimensions (width * new-height / height) × new-height
+// containing the original content scaled proportionally.
+#let _miniaturize(width, height, new-height, outer-style: (), content) = {
+ let factor = new-height / height * 100%
+ let new-width = width * factor
+ box(
+ stroke: black,
+ width: new-width,
+ height: new-height,
+ ..outer-style,
+ scale(
+ x: factor,
+ y: factor,
+ reflow: true,
+ box(width: width, height: height, align(left + top, content)),
+ ),
+ )
+}
+
+// get page extra args for show-notes-on-second-screen
+#let _get-page-extra-args(self) = {
+ if self.show-notes-on-second-screen in (bottom, right) {
+ let margin = self.page.margin
+ let (page-width, page-height) = utils.get-page-dimensions(self)
+ if (
+ type(margin) != dictionary
+ and type(margin) != length
+ and type(margin) != relative
+ ) {
+ return (:)
+ }
+ if type(margin) == length or type(margin) == relative {
+ margin = (x: margin, y: margin)
+ }
+ if self.show-notes-on-second-screen == bottom {
+ if "bottom" not in margin {
+ assert("y" in margin, message: "The margin should have bottom or y")
+ margin.bottom = margin.y
+ }
+ margin.bottom += page-height
+ return (margin: margin, height: 2 * page-height)
+ } else if self.show-notes-on-second-screen == right {
+ if "right" not in margin {
+ assert("x" in margin, message: "The margin should have right or x")
+ margin.right = margin.x
+ }
+ margin.right += page-width
+ return (margin: margin, width: 2 * page-width)
+ }
+ return (:)
+ } else {
+ return (:)
+ }
+}
+
+#let _get-header-footer(self) = {
+ let header = utils.call-or-display(self, self.page.at(
+ "header",
+ default: none,
+ ))
+ let footer = utils.call-or-display(self, self.page.at(
+ "footer",
+ default: none,
+ ))
+ let body-transform = body => body
+ // negative padding
+ if self.at("zero-margin-header", default: true) {
+ let negative-pad = _get-negative-pad(self)
+ header = negative-pad(header)
+ }
+ if self.at("zero-margin-footer", default: true) {
+ let negative-pad = _get-negative-pad(self)
+ footer = negative-pad(footer)
+ }
+ if self.at("show-notes-on-second-screen", default: none) == bottom {
+ let bottom-pad = _get-bottom-pad(self)
+ footer = bottom-pad(footer)
+ }
+ // speaker note (full-screen notes mode with slide thumbnail)
+ if self.at("show-only-notes", default: false) {
+ let (page-width, page-height) = utils.get-page-dimensions(self)
+ let show-only-notes = (self.methods.show-only-notes)(
+ self: self,
+ width: page-width,
+ height: page-height,
+ cutout: true,
+ )
+
+ let margin-left = if type(self.page.margin) != dictionary {
+ self.page.margin
+ } else if "left" in self.page.margin {
+ self.page.margin.left
+ } else if "x" in self.page.margin {
+ self.page.margin.x
+ } else {
+ 0pt
+ }
+
+ let margin-right = if type(self.page.margin) != dictionary {
+ self.page.margin
+ } else if "right" in self.page.margin {
+ self.page.margin.right
+ } else if "x" in self.page.margin {
+ self.page.margin.x
+ } else {
+ 0pt
+ }
+
+ let margin-top = if type(self.page.margin) != dictionary {
+ self.page.margin
+ } else if "top" in self.page.margin {
+ self.page.margin.top
+ } else if "y" in self.page.margin {
+ self.page.margin.y
+ } else {
+ 0pt
+ }
+
+ let margin-bottom = if type(self.page.margin) != dictionary {
+ self.page.margin
+ } else if "bottom" in self.page.margin {
+ self.page.margin.bottom
+ } else if "y" in self.page.margin {
+ self.page.margin.y
+ } else {
+ 0pt
+ }
+
+ let cutout-height = show-only-notes.cutout-height
+ let inset = (left: margin-left, right: margin-right)
+
+ // header: place notes background + thumbnail of slide header
+ header = {
+ place(
+ left + bottom,
+ dx: -margin-left,
+ dy: margin-top,
+ show-only-notes.background,
+ )
+ place(
+ right + top,
+ dx: margin-right,
+ _miniaturize(
+ page-width,
+ page-height,
+ cutout-height,
+ outer-style: (fill: white),
+ box(
+ width: 100%,
+ height: 100%,
+ inset: (bottom: page-height - margin-top, ..inset),
+ align(horizon, header),
+ ),
+ ),
+ )
+ }
+
+ // footer: place notes foreground + thumbnail of slide footer
+ footer = {
+ place(
+ right + bottom,
+ dx: margin-right,
+ dy: -(page-height - cutout-height),
+ _miniaturize(
+ page-width,
+ page-height,
+ cutout-height,
+ box(
+ width: 100%,
+ height: 100%,
+ inset: (top: page-height - margin-bottom, ..inset),
+ align(horizon, footer),
+ ),
+ ),
+ )
+ place(
+ left + bottom,
+ dx: -margin-left,
+ show-only-notes.foreground,
+ )
+ }
+
+ // body-transform: miniaturize the slide body and place in top-right corner
+ body-transform = body => place(
+ right + top,
+ dx: margin-right,
+ dy: -margin-top,
+ _miniaturize(
+ page-width,
+ page-height,
+ cutout-height,
+ box(
+ width: 100%,
+ height: 100%,
+ inset: (top: margin-top, bottom: margin-bottom, ..inset),
+ body,
+ ),
+ ),
+ )
+ } else if self.show-notes-on-second-screen in (bottom, right) {
+ // speaker note (second-screen mode)
+ let (page-width, page-height) = utils.get-page-dimensions(self)
+ let show-only-notes = (self.methods.show-only-notes)(
+ self: self,
+ width: page-width,
+ height: page-height,
+ )
+ let margin-left = if type(self.page.margin) != dictionary {
+ self.page.margin
+ } else if "left" in self.page.margin {
+ self.page.margin.left
+ } else if "x" in self.page.margin {
+ self.page.margin.x
+ } else {
+ 0pt
+ }
+ if self.show-notes-on-second-screen == bottom {
+ footer += place(
+ left + bottom,
+ dx: -margin-left,
+ show-only-notes,
+ )
+ } else if self.show-notes-on-second-screen == right {
+ footer += place(
+ left + bottom,
+ dx: page-width - margin-left,
+ show-only-notes,
+ )
+ }
+ }
+ (header, footer, body-transform)
+}
+
+#let _rewind-states(states, location) = {
+ for s in states {
+ if type(s) == dictionary {
+ (s.update)((s.at)(selector(location)))
+ } else {
+ s.update(s.at(selector(location)))
+ }
+ }
+}
+
+
+#let _parse-negative-subslide-indices(self, idx) = {
+ if type(idx) == int and idx < 0 {
+ idx = self.repeat + idx + 1
+ }
+ if type(idx) == array {
+ idx = idx.map(i => if i < 0 { self.repeat + i + 1 } else { i })
+ }
+ idx
+}
+
+#let print-invisible-headings(self) = {
+ if self.at("headings", default: ()) != () {
+ let headings = self
+ .at("headings", default: ())
+ .map(it => {
+ set heading(offset: 0)
+ show heading: none
+ if it.has("label") {
+ if (
+ str(it.label)
+ in (
+ "touying:hidden",
+ "touying:unnumbered",
+ "touying:unoutlined",
+ "touying:unbookmarked",
+ )
+ ) {
+ let fields = it.fields()
+ let _ = fields.remove("label", default: none)
+ let _ = fields.remove("body", default: none)
+ if str(it.label) == "touying:hidden" {
+ fields.numbering = none
+ fields.outlined = false
+ fields.bookmarked = false
+ }
+ if str(it.label) == "touying:unnumbered" {
+ fields.numbering = none
+ }
+ if str(it.label) == "touying:unoutlined" {
+ fields.outlined = false
+ }
+ if str(it.label) == "touying:unbookmarked" {
+ fields.bookmarked = false
+ }
+ [#heading(..fields, it.body)#it.label]
+ } else {
+ it
+ }
+ } else {
+ it
+ }
+ })
+ headings.sum(default: none)
+ }
+}
+
+// Internal slide rendering function. Called by theme slide functions via `touying-slide-wrapper`.
+// See the public `slide` function for parameter documentation.
+#let touying-slide(
+ self: none,
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = {
+ if config != (:) {
+ self = utils.merge-dicts(self, config)
+ }
+ assert(
+ bodies.named().len() == 0,
+ message: "unexpected named arguments:" + repr(bodies.named().keys()),
+ )
+ let setting-fn(body) = {
+ set heading(offset: self.at("slide-level", default: 0)) if self.at(
+ "auto-offset-for-heading",
+ default: true,
+ )
+ show: body => {
+ if self.at("show-strong-with-alert", default: true) {
+ show strong: self.methods.alert.with(self: self)
+ body
+ } else {
+ body
+ }
+ }
+ setting(body)
+ // Weak zero-height spacing that acts as a layout anchor for the current
+ // slide. When a slide body consists entirely of context (lazily evaluated)
+ // elements, Typst may not commit to the new page in the first layout pass.
+ // This causes hidden headings placed at the start of the *next* slide's
+ // preamble to receive the wrong page number, which in turn makes context
+ // queries like `display-current-heading` return the wrong heading.
+ // Adding `v(0pt, weak: true)` forces Typst to finalize the current page
+ // without adding any visible space (the weak flag suppresses it when
+ // adjacent to other spacing).
+ // See: https://github.com/touying-typ/touying/issues/388
+ v(0pt, weak: true)
+ }
+ let composer-with-cols(..args) = {
+ let effective-composer = if composer != auto {
+ composer
+ } else {
+ self.at("default-composer", default: auto)
+ }
+ if type(effective-composer) == function {
+ effective-composer(..args)
+ } else {
+ components.cols(
+ lazy-layout: false,
+ columns: effective-composer,
+ ..args,
+ )
+ }
+ }
+ let bodies = bodies.pos()
+
+ // Slide and subslide preamble functions for setup and metadata
+ let slide-preamble(self) = {
+ // do the leading function calls first
+ let leading-preamble = self
+ .at("leading-preamble", default: ())
+ .sum(default: none)
+ utils.call-or-display(self, leading-preamble)
+
+ if self.at("is-first-slide", default: false) {
+ utils.call-or-display(self, self.at("preamble", default: none))
+ utils.call-or-display(self, self.at("default-preamble", default: none))
+ }
+ [#metadata((kind: "touying-new-slide")) <touying-metadata>]
+ // add headings for the first subslide
+ print-invisible-headings(self)
+ }
+ // preamble for the subslides
+ let subslide-preamble(self) = {
+ if (
+ (self.handout and not self.at("_handout-secondary", default: false))
+ or self.subslide == 1
+ ) {
+ slide-preamble(self)
+ }
+ [#metadata((kind: "touying-new-subslide")) <touying-metadata>]
+ if (
+ self.at("enable-frozen-states-and-counters", default: true)
+ and not self.handout
+ and self.repeat > 1
+ ) {
+ if self.subslide == 1 {
+ context {
+ utils.loc-prior-newslide.update(here())
+ }
+ } else {
+ context {
+ let loc-prior-newslide = utils.loc-prior-newslide.get()
+ _rewind-states(self.frozen-states, loc-prior-newslide)
+ _rewind-states(self.default-frozen-states, loc-prior-newslide)
+ _rewind-states(self.frozen-counters, loc-prior-newslide)
+ _rewind-states(self.default-frozen-counters, loc-prior-newslide)
+ }
+ }
+ }
+ utils.call-or-display(self, self.at("subslide-preamble", default: none))
+ utils.call-or-display(self, self.at(
+ "default-subslide-preamble",
+ default: none,
+ ))
+ // Process speaker-notes that were attached from outside the slide
+ // (e.g. #speaker-note[] immediately after #slide[]).
+ for note in self.at("attached-speaker-notes", default: ()) {
+ utils.speaker-note(
+ self: self,
+ mode: note.mode,
+ setting: note.setting,
+ subslide: if note.subslide == auto { none } else { note.subslide },
+ note.note,
+ )
+ }
+ }
+ // update states for every page
+ let page-preamble(self) = {
+ [#metadata((kind: "touying-new-page")) <touying-metadata>]
+ // 1. slide counter part
+ // if freeze-slide-counter is false, then update the slide-counter
+ if (
+ (self.handout and not self.at("_handout-secondary", default: false))
+ or self.subslide == 1
+ ) {
+ if not self.at("freeze-slide-counter", default: false) {
+ utils.slide-counter.step()
+ // if appendix is false, then update the last-slide-counter
+ if not self.at("appendix", default: false) {
+ utils.last-slide-counter.step()
+ }
+ }
+ }
+ utils.call-or-display(self, self.at("page-preamble", default: none))
+ utils.call-or-display(self, self.at("default-page-preamble", default: none))
+ }
+
+ self.subslide = 1
+ // Pre-collect waypoints so label resolution works during the initial parse pass.
+ // If any body is a function (callback-style slide), call it with the current self
+ // to materialise content. The global #waypoint/#uncover/#only functions only emit
+ // metadata and do not rely on self.waypoints, so this is safe.
+ let self-for-prepass = self + (_waypoint-prepass: true)
+ let resolved-bodies = bodies.map(b => if type(b) == function {
+ b(self-for-prepass)
+ } else { b })
+ // Set preliminary single-point ranges (first == last == raw subslide number);
+ // proper ranges are computed after `repeat` is known.
+ let (raw-waypoints, start-overrides, decl-reps) = _collect-waypoints(
+ ..resolved-bodies,
+ )
+ // Resolve explicit `start` overrides (forest resolution) before anything
+ // else, so that every waypoint has its final subslide position.
+ let raw-waypoints = _resolve-waypoint-forest(raw-waypoints, start-overrides)
+ self.waypoints = (:)
+ for (lbl, sub) in raw-waypoints.pairs() {
+ self.waypoints.insert(lbl, (first: sub, last: sub))
+ }
+ // for single page slide, get the repetitions
+ if repeat == auto {
+ let (
+ _,
+ repetitions,
+ last-subslide,
+ _,
+ _,
+ ) = _parse-content-into-results-and-repetitions(
+ self: self,
+ base: 1,
+ index: 1,
+ ..bodies,
+ )
+ repeat = calc.max(repetitions, last-subslide)
+ }
+ // Ensure repeat covers all resolved waypoint positions (e.g. start: 5
+ // requires at least 5 subslides even if the content has fewer pauses).
+ if raw-waypoints.len() > 0 {
+ repeat = calc.max(repeat, calc.max(..raw-waypoints.values()))
+ }
+ assert(type(repeat) == int, message: "The repeat should be an integer")
+ self.repeat = repeat
+ // Recompute waypoint ranges with the actual repeat count
+ self.waypoints = _compute-waypoint-ranges(
+ raw-waypoints,
+ repeat,
+ start-overrides,
+ decl-reps,
+ )
+ // page header and footer
+ let (header, footer, body-transform) = _get-header-footer(self)
+ let page-extra-args = _get-page-extra-args(self)
+
+ let _resolve-handout-waypoint(self, lbl) = {
+ let resolved = utils.resolve-waypoints(self, lbl)
+ if type(resolved) == int {
+ (resolved,)
+ } else if type(resolved) == dictionary {
+ // resolve waypoint to the first subslide. This is how waypoints are always resolved for single integer application like some `start`field.
+ let first = resolved.at("beginning", default: resolved.at(
+ "first",
+ default: 1,
+ ))
+ // let last = resolved.at("until", default: resolved.at(
+ // "last",
+ // default: repeat,
+ // ))
+ (first,)
+ } else {
+ panic(
+ "touying-slide: unexpected resolved waypoint type for handout-subslides: "
+ + repr(resolved),
+ )
+ }
+ }
+
+ if self.handout {
+ let handout-subslides = self.at("handout-subslides", default: none)
+ if handout-subslides == none {
+ // Original behavior: render only the last subslide
+ self.subslide = repeat
+ let (conts, _, _, _, _) = _parse-content-into-results-and-repetitions(
+ self: self,
+ index: repeat,
+ show-delayed-wrapper: true,
+ ..bodies,
+ )
+ header = page-preamble(self) + header
+ let slide-body = body-transform(setting-fn(
+ subslide-preamble(self) + composer-with-cols(..conts),
+ ))
+ return {
+ set page(
+ ..(self.page + page-extra-args + (header: header, footer: footer)),
+ )
+ if self.at("breakable", default: true) {
+ slide-body
+ } else {
+ components.page-container(
+ self: self,
+ clip: self.at("clip", default: false),
+ detect-overflow: self.at("detect-overflow", default: true),
+ slide-body,
+ )
+ }
+ }
+ }
+
+ if type(handout-subslides) == array {
+ for (i, subslide-idx) in handout-subslides.enumerate() {
+ if (
+ type(subslide-idx) == label
+ or (
+ type(subslide-idx) == dictionary
+ and subslide-idx.at("kind", default: "") in waypoint-kinds
+ )
+ ) {
+ handout-subslides[i] = _resolve-handout-waypoint(self, subslide-idx) //resolve waypoint labels to first subslide, only for handout
+ } else if type(subslide-idx) == int or type(subslide-idx) == str {
+ // do nothing
+ } else {
+ panic(
+ "touying-slide: if handout-subslides is an array, it must be integers, strings, or waypoint labels/markers, got type "
+ + str(type(subslide-idx))
+ + " for element "
+ + repr(subslide-idx),
+ )
+ }
+ }
+ }
+ //negative indices in string not defined/supported, and they can even have ! for inversion.
+ let handout-subslides = _parse-negative-subslide-indices(
+ self,
+ handout-subslides,
+ )
+
+ // Render only the subslides that match handout-subslides
+ let handout-subslide-indices = range(1, repeat + 1).filter(
+ i => utils.check-visible(i, handout-subslides),
+ )
+ // Fall back to the last subslide if none match
+ if handout-subslide-indices.len() == 0 {
+ handout-subslide-indices = (repeat,)
+ }
+ let result = ()
+ for (pos, i) in handout-subslide-indices.enumerate() {
+ let is-first = pos == 0
+ let is-last = pos == handout-subslide-indices.len() - 1
+ let subslide-self = self
+ subslide-self.subslide = i
+ // Disable frozen states for handout multi-subslide rendering
+ subslide-self.enable-frozen-states-and-counters = false
+ // For non-first subslides, mark as a secondary handout page so that
+ // slide/page preambles and the slide counter are not repeated, while
+ // keeping handout: true so that handout-only content remains visible.
+ if not is-first {
+ subslide-self._handout-secondary = true
+ }
+ let (header-i, footer-i, body-transform-i) = _get-header-footer(
+ subslide-self,
+ )
+ let (conts, _, _, _, _) = _parse-content-into-results-and-repetitions(
+ self: subslide-self,
+ index: i,
+ show-delayed-wrapper: is-last,
+ ..bodies,
+ )
+ let new-header = page-preamble(subslide-self) + header-i
+ let slide-body = body-transform-i(setting-fn(
+ subslide-preamble(subslide-self) + composer-with-cols(..conts),
+ ))
+ result.push({
+ set page(
+ ..(
+ subslide-self.page
+ + page-extra-args
+ + (header: new-header, footer: footer-i)
+ ),
+ )
+ if subslide-self.at("breakable", default: true) {
+ slide-body
+ } else {
+ components.page-container(
+ self: subslide-self,
+ clip: subslide-self.at("clip", default: false),
+ detect-overflow: subslide-self.at("detect-overflow", default: true),
+ slide-body,
+ )
+ }
+ })
+ }
+
+ result.sum(default: none)
+ } else if self.at("_recall-subslide", default: none) != none {
+ // Render specific subslide(s) requested by touying-recall.
+ // The spec is resolved here because `repeat` and `self.waypoints`
+ // are only available after the pre-pass above.
+ let recall-spec = self._recall-subslide
+ let recall-indices = if recall-spec == auto {
+ // auto → last subslide only
+ (repeat,)
+ } else if type(recall-spec) == int {
+ // Explicit single subslide
+ (_parse-negative-subslide-indices(self, recall-spec),)
+ } else if type(recall-spec) == str and recall-spec == "waypoints" {
+ // "waypoints" → last subslide of every waypoint
+ let wp-map = self.at("waypoints", default: (:))
+ if wp-map.len() == 0 {
+ (repeat,)
+ } else {
+ let sorted = wp-map.pairs().sorted(key: p => p.at(1).first)
+ sorted.map(p => p.at(1).last).dedup()
+ }
+ } else if type(recall-spec) == label or type(recall-spec) == dictionary {
+ // Waypoint label or marker — resolve using the slide's waypoint map
+ let resolved = utils.resolve-waypoints(self, recall-spec)
+ if type(resolved) == int {
+ (resolved,)
+ } else if type(resolved) == dictionary {
+ //waypoints resolve to the whole animation sequence. If specific slides are needed use the waypoint marker functions.
+ let first = resolved.at("beginning", default: resolved.at(
+ "first",
+ default: 1,
+ ))
+ let last = resolved.at("until", default: resolved.at(
+ "last",
+ default: repeat,
+ ))
+ range(first, last + 1)
+ } else {
+ panic(
+ "touying-recall: unexpected resolved waypoint type: "
+ + repr(resolved),
+ )
+ }
+ } else {
+ panic(
+ "touying-recall: subslide must be none, auto, int, \"waypoints\", label, or waypoint marker, got "
+ + str(type(recall-spec)),
+ )
+ }
+ // Validate and render each requested subslide
+ let result = ()
+ for i in recall-indices {
+ assert(
+ i >= 1 and i <= repeat,
+ message: "touying-recall: subslide "
+ + str(i)
+ + " is out of range (1.."
+ + str(repeat)
+ + ")",
+ )
+ self.subslide = i
+ let (header, footer, body-transform) = _get-header-footer(self)
+ let (conts, _, _, _, _) = _parse-content-into-results-and-repetitions(
+ self: self,
+ index: i,
+ show-delayed-wrapper: i == repeat,
+ ..bodies,
+ )
+ let new-header = page-preamble(self) + header
+ let slide-body = body-transform(setting-fn(
+ subslide-preamble(self) + composer-with-cols(..conts),
+ ))
+ result.push({
+ set page(
+ ..(
+ self.page + page-extra-args + (header: new-header, footer: footer)
+ ),
+ )
+ if self.at("breakable", default: true) {
+ slide-body
+ } else {
+ components.page-container(
+ self: self,
+ clip: self.at("clip", default: false),
+ detect-overflow: self.at("detect-overflow", default: true),
+ slide-body,
+ )
+ }
+ })
+ }
+ result.sum()
+ } else {
+ // render all the subslides
+ let result = ()
+ for i in range(1, repeat + 1) {
+ self.subslide = i
+ let (header, footer, body-transform) = _get-header-footer(self)
+ let delayed-args = if i == repeat {
+ (show-delayed-wrapper: true)
+ }
+ let (conts, _, _, _, _) = _parse-content-into-results-and-repetitions(
+ self: self,
+ index: i,
+ ..delayed-args,
+ ..bodies,
+ )
+ let new-header = page-preamble(self) + header
+ // update the counter in the first subslide only
+ let slide-body = body-transform(setting-fn(
+ subslide-preamble(self) + composer-with-cols(..conts),
+ ))
+ result.push({
+ set page(
+ ..(
+ self.page + page-extra-args + (header: new-header, footer: footer)
+ ),
+ )
+ if self.at("breakable", default: true) {
+ slide-body
+ } else {
+ components.page-container(
+ self: self,
+ clip: self.at("clip", default: false),
+ detect-overflow: self.at("detect-overflow", default: true),
+ slide-body,
+ )
+ }
+ })
+ }
+ // return the result
+ result.sum()
+ }
+}
+
+
+/// Touying slide function.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - repeat (auto, int): The number of subslides. Default is `auto`, which means touying will automatically calculate the number of subslides.
+///
+/// The `repeat` argument is necessary when you use `#slide(repeat: 3, self => [ .. ])` style code to create a slide. The callback-style `uncover` and `only` cannot be detected by touying automatically.
+///
+/// - setting (function): The setting of the slide. You can use it to add some set/show rules for the slide.
+///
+/// - composer (function, array, int, auto): The composer arranges multiple content bodies side by side.
+///
+/// - `auto`: use the theme default (`cols.with(lazy-layout: false)`)
+/// - array, e.g. `(1fr, 2fr, 1fr)`: column widths for `cols`
+/// - int: equal columns shorthand
+/// - function: fully custom layout, e.g. `grid.with(columns: 2)`
+///
+/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` splits the slide into three columns.
+///
+/// If you want to customize the composer, you can pass a function like `#slide(composer: grid.with(columns: 2))[A][B]`.
+///
+/// - bodies (array): The contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide.
+///
+/// -> content
+#let slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ if self.slide-fn != slide {
+ let wrapper = (self.slide-fn)(
+ config: config,
+ repeat: repeat,
+ setting: setting,
+ composer: composer,
+ ..bodies,
+ )
+ (wrapper.value.fn)(self)
+ } else {
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: setting,
+ composer: composer,
+ ..bodies,
+ )
+ }
+})
+
+
+
+/// Empty slide with no default heading or section context.
+///
+/// Unlike `slide`, this function does not look at heading context or trigger `new-section-slide-fn` / `new-subsection-slide-fn`. Use it to create isolated slides outside the normal slide hierarchy (e.g. a standalone title card).
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - repeat (auto, int): The number of subslides. Default is `auto`, which means touying will automatically calculate the number of subslides.
+///
+/// - setting (function): Set/show rules to apply for the slide. Receives the composed body and returns it.
+///
+/// - composer (function, array, int, auto): Arranges multiple body blocks cols. Same semantics as `slide`.
+///
+/// - bodies (arguments): The content blocks of the slide.
+///
+/// -> content
+#let empty-slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: setting,
+ composer: composer,
+ ..bodies,
+ )
+})
--- /dev/null
+#import "core.typ": (
+ alert,
+ alternatives,
+ alternatives-cases,
+ alternatives-fn,
+ alternatives-match,
+ appendix,
+ effect,
+ empty-slide,
+ from-wp,
+ get-first,
+ get-last,
+ handout-only,
+ item-by-item,
+ item-by-item-fn,
+ item-by-item-functions,
+ jump,
+ meanwhile,
+ next-wp,
+ not-wp,
+ only,
+ pause,
+ prev-wp,
+ slide,
+ speaker-note,
+ touying-equation,
+ touying-fn-wrapper,
+ touying-fn-wrapper-raw,
+ touying-mitex,
+ touying-raw,
+ touying-recall,
+ touying-reducer,
+ touying-set-config, // touying-get-config from configs.typ
+ touying-slide,
+ touying-slide-wrapper,
+ uncover,
+ until-wp,
+ waypoint,
+)
+#import "configs.typ": (
+ config-colors, config-common, config-info, config-methods, config-page,
+ config-store, default-config, touying-get-config,
+)
+#import "slides.typ": touying-slides
+#import "utils.typ"
+#import "magic.typ"
+#import "pdfpc.typ"
+#import "components.typ": cols, lazy-h, lazy-layout, lazy-v, side-by-side
+#import "components.typ"
+
+#import "extern.typ": touying-disable-warnings, touying-enable-warnings
--- /dev/null
+#import "@preview/uniwarn:0.1.1"
+#uniwarn.register-namespace("touying")
+#let warning = uniwarn.warning.with(namespace: "touying", prefix: "[touying] ")
+#let touying-enable-warnings = uniwarn.enable-warnings.with("touying")
+#let touying-disable-warnings = uniwarn.disable-warnings.with("touying")
--- /dev/null
+// ---------------------------------------------------------------------
+// List, Enum, and Terms
+// ---------------------------------------------------------------------
+
+
+/// Apply as a show rule to vertically align list markers with the baseline of the first line of each list item. This prevents markers from appearing too high when list items have tall content.
+///
+/// Usage: `#show: align-list-marker-with-baseline`
+///
+/// -> content
+#let align-list-marker-with-baseline(body) = {
+ show list.item: it => {
+ let current-marker = {
+ set text(fill: text.fill)
+ if type(list.marker) == array {
+ list.marker.at(0)
+ } else {
+ list.marker
+ }
+ }
+ let hanging-indent = measure(current-marker).width + .6em + .3pt
+ set terms(hanging-indent: hanging-indent)
+ if type(list.marker) == array {
+ terms.item(
+ current-marker,
+ {
+ // set the value of list.marker in a loop
+ set list(marker: list.marker.slice(1) + (list.marker.at(0),))
+ it.body
+ },
+ )
+ } else {
+ terms.item(current-marker, it.body)
+ }
+ }
+ body
+}
+
+/// Apply as a show rule to vertically align enum markers with the baseline of the first line of each enum item. Only works for numeric markers (e.g. `1.`).
+///
+/// Usage: `#show: align-enum-marker-with-baseline`
+///
+/// -> content
+#let align-enum-marker-with-baseline(body) = {
+ let counting-symbols = "1aAiI一壹あいアイא가ㄱ*"
+ let consume-regex = regex(
+ "[^"
+ + counting-symbols
+ + "]*["
+ + counting-symbols
+ + "][^"
+ + counting-symbols
+ + "]*",
+ )
+
+ show enum.item: it => {
+ if it.number == none {
+ return it
+ }
+ let new-numbering = if type(enum.numbering) == function or enum.full {
+ numbering.with(enum.numbering, it.number)
+ } else {
+ enum.numbering.trim(consume-regex, at: start, repeat: false)
+ }
+ let current-number = numbering(enum.numbering, it.number)
+ set terms(hanging-indent: 1.2em)
+ terms.item(
+ strong(delta: -strong.delta, numbering(enum.numbering, it.number)),
+ {
+ if new-numbering != "" {
+ set enum(numbering: new-numbering)
+ it.body
+ } else {
+ it.body
+ }
+ },
+ )
+ }
+
+ body
+}
+
+/// Scale the font size of nested list, enum, and terms items.
+///
+/// Usage: `#show: scale-list-items.with(scale: .75)`
+///
+/// - scale (int, float): The font size ratio of the current nesting level relative to the parent. Default is `.75`.
+///
+/// - body (content): The content to apply the scaling to.
+///
+/// -> content
+#let scale-list-items(
+ scale: .75,
+ body,
+) = {
+ show list.where().or(enum.where().or(terms)): it => {
+ show list.where().or(enum.where().or(terms)): set text(scale * 1em)
+ it
+ }
+ body
+}
+
+/// Convert a single tight list, enum, or terms element to non-tight (with spacing between items). For use in show rules.
+///
+/// Usage: `#show list: nontight(list)`
+///
+/// - lst (content): A list, enum, or terms element to make non-tight.
+///
+/// -> content
+#let nontight(lst) = {
+ let fields = lst.fields()
+ fields.remove("children")
+ fields.tight = false
+ return (lst.func())(..fields, ..lst.children)
+}
+
+/// Apply as a show rule to make all lists, enumerations, and term lists use non-tight spacing by default (adds spacing between items).
+///
+/// Usage: `#show: nontight-list-enum-and-terms`
+///
+/// -> content
+#let nontight-list-enum-and-terms(body) = {
+ show list.where(tight: true): nontight
+ show enum.where(tight: true): nontight
+ show terms.where(tight: true): nontight
+ body
+}
+
+/// Apply as a show rule to suppress list markers and enum numbering inside `#hide(...)` calls. This prevents phantom markers from taking up space in covered content.
+///
+/// Usage: `#show: show-hide-set-list-marker-none`
+///
+/// -> content
+#let show-hide-set-list-marker-none(body) = {
+ show hide: it => {
+ set list(marker: none)
+ set enum(numbering: (..nums) => none)
+
+ it
+ }
+ body
+}
+
+
+
+// ---------------------------------------------------------------------
+// Bibliography
+// ---------------------------------------------------------------------
+
+#let bibliography-state = state("footer-bibliography-state", ())
+#let bibliography-visited = state("footer-bibliography-visited", ())
+
+/// Display bibliography citations as footnotes. Place `#place(hide(bibliography(...)))` at the end of the document to register the bibliography entries.
+///
+/// Usage: `#show: magic.bibliography-as-footnote.with(bibliography(title: none, "ref.bib"))`
+///
+/// - numbering (str): The numbering format for footnote citations. Default is `"[1]"`.
+///
+/// - bibliography (bibliography): The bibliography element, e.g. `bibliography("ref.bib")`.
+///
+/// -> content
+#let bibliography-as-footnote(
+ numbering: "[1]",
+ bibliography,
+ body,
+) = {
+ show cite.where(form: "normal"): it => (
+ context {
+ let label-str = str(here().page()) + str(it.key)
+ let bibitem = {
+ show: body => {
+ show regex("^\[\d+\]\s"): it => ""
+ body
+ }
+ cite(it.key, form: "full")
+ }
+ if it.key not in bibliography-visited.get() {
+ bibliography-state.update(x => (..x, bibitem))
+ bibliography-visited.update(visited => visited + (it.key,))
+ }
+ box({
+ if query(selector(label(label-str)).before(here())).len() > 0 {
+ [#footnote(label(label-str), numbering: numbering)]
+ } else {
+ [#footnote(numbering: numbering, bibitem)#label(label-str)]
+ }
+ })
+ }
+ )
+
+ body
+}
+
+/// Display the collected bibliography entries. Avoids the "multiple bibliographies are not yet supported" error by rendering entries gathered by `bibliography-as-footnote`.
+///
+/// Usage: `#magic.bibliography()`
+///
+/// - title (str, auto, none): The heading for the bibliography section. When `auto`, uses a language-appropriate title. When `none`, no heading is shown. Default is `auto`.
+///
+/// -> content
+#let bibliography(title: auto) = {
+ context {
+ let title = title
+ let bibitems = bibliography-state.final()
+ if title == auto {
+ if text.lang == "zh" {
+ title = "参考文献"
+ } else {
+ title = "Bibliography"
+ }
+ }
+ if title != none {
+ heading(title)
+ v(.45em)
+ }
+ grid(
+ columns: (auto, 1fr),
+ column-gutter: .7em,
+ row-gutter: 1.2em,
+ ..range(bibitems.len())
+ .map(i => (numbering("[1]", i + 1), bibitems.at(i)))
+ .flatten(),
+ )
+ }
+}
--- /dev/null
+// Attribution: This file is based on the code from https://github.com/andreasKroepelin/polylux/blob/main/utils/pdfpc.typ
+// Author: Andreas Kröpelin
+
+/// Generate pdfpc metadata for the presentation. Called internally in the preamble when `enable-pdfpc` is `true`. Query the result with `typst query --root . ./example.typ --field value --one "<pdfpc-file>" > ./example.pdfpc`.
+///
+/// -> content
+#let pdfpc-file(loc) = {
+ let arr = query(<pdfpc>).map(it => it.value)
+ let (config, ..slides) = arr.split((t: "NewSlide"))
+ let pdfpc = (
+ pdfpcFormat: 2,
+ disableMarkdown: false,
+ )
+ for item in config {
+ pdfpc.insert(lower(item.t.at(0)) + item.t.slice(1), item.v)
+ }
+ let pages = ()
+ for slide in slides {
+ let page = (
+ idx: 0,
+ label: 1,
+ overlay: 0,
+ forcedOverlay: false,
+ hidden: false,
+ )
+ for item in slide {
+ if item.t == "Idx" {
+ page.idx = item.v
+ } else if item.t == "LogicalSlide" {
+ page.label = str(item.v)
+ } else if item.t == "Overlay" {
+ page.overlay = item.v
+ page.forcedOverlay = item.v > 0
+ } else if item.t == "HiddenSlide" {
+ page.hidden = true
+ } else if item.t == "SaveSlide" {
+ if "savedSlide" not in pdfpc {
+ pdfpc.savedSlide = page.label - 1
+ }
+ } else if item.t == "EndSlide" {
+ if "endSlide" not in pdfpc {
+ pdfpc.endSlide = page.label - 1
+ }
+ } else if item.t == "Note" {
+ page.note = if "note" in page {
+ page.note + "\n\n" + item.v
+ } else {
+ item.v
+ }
+ } else {
+ pdfpc.insert(lower(item.t.at(0)) + item.t.slice(1), item.v)
+ }
+ }
+ pages.push(page)
+ }
+ pdfpc.insert("pages", pages)
+ [#metadata(pdfpc)<pdfpc-file>]
+}
+
+/// Emit a raw speaker note string for the current slide into the pdfpc metadata. Called internally by `utils.speaker-note`.
+///
+/// -> content
+#let speaker-note(text) = {
+ let text = if type(text) == str {
+ text
+ } else if type(text) == content and text.func() == raw {
+ text.text.trim()
+ } else {
+ panic("A note must either be a string or a raw block")
+ }
+ [ #metadata((t: "Note", v: text)) <pdfpc> ]
+}
+
+#let end-slide = [
+ #metadata((t: "EndSlide")) <pdfpc>
+]
+
+#let save-slide = [
+ #metadata((t: "SaveSlide")) <pdfpc>
+]
+
+#let hidden-slide = [
+ #metadata((t: "HiddenSlide")) <pdfpc>
+]
+
+
+/// Configuration for the pdfpc export. You can export the pdfpc file by shell command `typst query --root . ./example.typ --field value --one "<pdfpc-file>" > ./example.pdfpc`.
+///
+/// Example:
+///
+/// ```typ
+/// #pdfpc.config(
+/// duration-minutes: 30,
+/// start-time: datetime(hour: 14, minute: 10, second: 0),
+/// end-time: datetime(hour: 14, minute: 40, second: 0),
+/// last-minutes: 5,
+/// note-font-size: 12,
+/// disable-markdown: false,
+/// default-transition: (
+/// type: "push",
+/// duration-seconds: 2,
+/// angle: ltr,
+/// alignment: "vertical",
+/// direction: "inward",
+/// ),
+/// )
+/// ```
+///
+/// - duration-minutes (int): The duration of the presentation in minutes.
+///
+/// - start-time (datetime): The start time of the presentation.
+///
+/// - end-time (datetime): The end time of the presentation.
+///
+/// - last-minutes (int): The number of minutes to show the last slide.
+///
+/// - note-font-size (float): The font size of the speaker notes.
+///
+/// - disable-markdown (bool): A flag to disable markdown in the speaker notes.
+///
+/// - default-transition (dictionary, none): The default slide transition. A dictionary with optional keys: `type` (str, e.g. `"push"`), `duration-seconds` (int), `angle` (direction, e.g. `ltr`), `alignment` (str, `"horizontal"` or `"vertical"`), `direction` (str, `"inward"` or `"outward"`). Default is `none`.
+///
+/// -> content
+#let config(
+ duration-minutes: none,
+ start-time: none,
+ end-time: none,
+ last-minutes: none,
+ note-font-size: none,
+ disable-markdown: false,
+ default-transition: none,
+) = {
+ if duration-minutes != none {
+ [ #metadata((t: "Duration", v: duration-minutes)) <pdfpc> ]
+ }
+
+ let _time-config(time, msg-name, tag-name) = {
+ let time = if type(time) == datetime {
+ time.display("[hour padding:zero repr:24]:[minute padding:zero]")
+ } else if type(time) == str {
+ time
+ } else {
+ panic(
+ msg-name
+ + " must be either a datetime or a string in the HH:MM format.",
+ )
+ }
+
+ [ #metadata((t: tag-name, v: time)) <pdfpc> ]
+ }
+
+ if start-time != none {
+ _time-config(start-time, "Start time", "StartTime")
+ }
+
+ if end-time != none {
+ _time-config(end-time, "End time", "EndTime")
+ }
+
+ if last-minutes != none {
+ [ #metadata((t: "LastMinutes", v: last-minutes)) <pdfpc> ]
+ }
+
+ if note-font-size != none {
+ [ #metadata((t: "NoteFontSize", v: note-font-size)) <pdfpc> ]
+ }
+
+ [ #metadata((t: "DisableMarkdown", v: disable-markdown)) <pdfpc> ]
+
+ if default-transition != none {
+ let dir-to-angle(dir) = if dir == ltr {
+ "0"
+ } else if dir == rtl {
+ "180"
+ } else if dir == ttb {
+ "90"
+ } else if dir == btt {
+ "270"
+ } else {
+ panic("angle must be a direction (ltr, rtl, ttb, or btt)")
+ }
+
+ let transition-str = (
+ default-transition.at("type", default: "replace")
+ + ":"
+ + str(
+ default-transition.at("duration-seconds", default: 1),
+ )
+ + ":"
+ + dir-to-angle(default-transition.at("angle", default: rtl))
+ + ":"
+ + default-transition.at(
+ "alignment",
+ default: "horizontal",
+ )
+ + ":"
+ + default-transition.at("direction", default: "outward")
+ )
+
+ [ #metadata((t: "DefaultTransition", v: transition-str)) <pdfpc> ]
+ }
+}
--- /dev/null
+#import "utils.typ"
+#import "configs.typ"
+#import "core.typ"
+#import "magic.typ"
+
+/// Touying slides function.
+///
+/// Example:
+///
+/// ```typst
+/// #show: touying-slides.with(
+/// config-page(paper: "presentation-" + aspect-ratio),
+/// config-common(
+/// slide-fn: slide,
+/// ),
+/// ..args,
+/// )
+/// ```
+///
+/// - args (arguments): The configurations of the slides. For example, you can use `config-page(paper: "presentation-16-9")` to set the aspect ratio of the slides.
+///
+/// - body (content): The contents of the slides.
+///
+/// -> content
+#let touying-slides(..args, body) = {
+ // get the default config
+ let args = (configs.default-config,) + args.pos()
+ let self = utils.merge-dicts(..args)
+
+ // set the document
+ set document(
+ ..(
+ if type(self.info.title) in (str, content) {
+ (title: self.info.title)
+ } else {}
+ ),
+ ..(
+ if type(self.info.author) in (str, array) {
+ (author: self.info.author)
+ } else if (
+ type(self.info.author) == content
+ ) { (author: utils.markup-text(self.info.author)) } else {}
+ ),
+ ..(
+ if type(self.info.date) in (datetime,) { (date: self.info.date) } else {}
+ ),
+ )
+
+ // get the init function
+ let init = if "init" in self.methods and type(self.methods.init) == function {
+ self.methods.init.with(self: self)
+ } else {
+ body => body
+ }
+
+ show: body => {
+ if self.at("scale-list-items", default: none) != none {
+ magic.scale-list-items(
+ scale: self.at("scale-list-items", default: none),
+ body,
+ )
+ } else {
+ body
+ }
+ }
+
+ show: body => {
+ if self.at("nontight-list-enum-and-terms", default: true) {
+ magic.nontight-list-enum-and-terms(body)
+ } else {
+ body
+ }
+ }
+
+ show: body => {
+ if self.at("align-enum-marker-with-baseline", default: false) {
+ magic.align-enum-marker-with-baseline(body)
+ } else {
+ body
+ }
+ }
+
+ show: body => {
+ if self.at("align-list-marker-with-baseline", default: false) {
+ magic.align-list-marker-with-baseline(body)
+ } else {
+ body
+ }
+ }
+
+ show: body => {
+ if self.at("show-hide-set-list-marker-none", default: true) {
+ magic.show-hide-set-list-marker-none(body)
+ } else {
+ body
+ }
+ }
+
+ show: body => {
+ if self.at("show-bibliography-as-footnote", default: none) != none {
+ let args = self.at("show-bibliography-as-footnote", default: none)
+ if type(args) == dictionary {
+ let bibliography = args.at("bibliography")
+ args.remove("bibliography")
+ magic.show-bibliography-as-footnote.with(
+ ..args,
+ bibliography,
+ body,
+ )
+ } else {
+ // args is a bibliography like `bibliography(title: none, "ref.bib")`
+ magic.bibliography-as-footnote(args, body)
+ }
+ } else {
+ body
+ }
+ }
+
+ show: init
+
+ show: core.split-content-into-slides.with(self: self, is-first-slide: true)
+
+ body
+}
--- /dev/null
+#import "pdfpc.typ"
+#import "extern.typ": warning
+/// Add page margin dictionary to another page margin dictionary.
+///
+/// Example: `add-page-margin-dicts((top: 1cm, x: 2cm), (y: 3em))` returns `(x: 2cm, y: 3em)`
+///
+/// - dict-a (dictionary): The base dictionary.
+///
+/// - dict-b (dictionary): The dictionary to merge into `dict-a`.
+///
+/// -> dictionary
+#let add-page-margin-dicts(dict-a, dict-b) = {
+ // Possible keys: top, right, bottom, left, inside, outside, x, y, rest.
+ // Source: https://github.com/typst/typst/blob/e7256a6361f3181bac6d61cfd31935a443109bfb/crates/typst-library/src/layout/page.rs#L128-L139
+ let res = dict-a
+ let sides = ("top", "right", "bottom", "left")
+ let res-has-sides = res.keys().any(k => k in sides)
+ // `rest` only works as expected with its context, i.e., `dict-b`.
+ if "rest" in dict-b { return dict-b }
+ if not res-has-sides { return res + dict-b }
+ // Assuming `inside`/`outside` just takes precedence over `x`/`left`/`right`.
+ if "x" in dict-b {
+ if "left" in res { _ = res.remove("left") }
+ if "right" in res { _ = res.remove("right") }
+ }
+ if "y" in dict-b {
+ if "top" in res { _ = res.remove("top") }
+ if "bottom" in res { _ = res.remove("bottom") }
+ }
+ return res + dict-b
+}
+
+/// Add a dictionary to another dictionary recursively.
+///
+/// Example: `add-dicts((a: (b: 1)), (a: (c: 2)))` returns `(a: (b: 1, c: 2))`
+///
+/// - dict-a (dictionary): The base dictionary.
+///
+/// - dict-b (dictionary): The dictionary to merge into `dict-a`.
+///
+/// -> dictionary
+#let add-dicts(dict-a, dict-b) = {
+ let res = dict-a
+ for key in dict-b.keys() {
+ if (
+ key in res
+ and type(res.at(key)) == dictionary
+ and type(dict-b.at(key)) == dictionary
+ ) {
+ if key == "margin" {
+ // Assuming `margin` can only be in the `config-page`.
+ res.insert(key, add-page-margin-dicts(res.at(key), dict-b.at(key)))
+ } else {
+ res.insert(key, add-dicts(res.at(key), dict-b.at(key)))
+ }
+ } else {
+ res.insert(key, dict-b.at(key))
+ }
+ }
+ return res
+}
+
+
+/// Merge some dictionaries recursively.
+///
+/// Example: `merge-dicts((a: (b: 1)), (a: (c: 2)))` returns `(a: (b: 1, c: 2))`
+///
+/// - init-dict (dictionary): The initial dictionary to start from.
+///
+/// - dicts (array): Additional dictionaries to merge in order.
+///
+/// -> dictionary
+#let merge-dicts(init-dict, ..dicts) = {
+ assert(
+ dicts.named().len() == 0,
+ message: "You must provide dictionaries as positional arguments",
+ )
+ let res = init-dict
+ for dict in dicts.pos() {
+ res = add-dicts(res, dict)
+ }
+ return res
+}
+
+// -------------------------------------
+// Slide counter
+// -------------------------------------
+#let slide-counter = counter("touying-slide-counter")
+#let last-slide-counter = counter("touying-last-slide-counter")
+#let last-slide-number = context last-slide-counter.final().first()
+
+/// Get the progress of the current slide.
+///
+/// `utils.last-slide-number` gives the total slide count and can be used directly in headers or footers.
+///
+/// #example(
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+/// = Slide
+///
+/// #touying-progress(ratio => {
+/// "Progress: " + str(int(ratio * 100)) + "%"
+/// })
+/// )
+///
+/// - callback (function): A function `ratio => { .. }` receiving a float between `0.0` and `1.0`.
+///
+/// -> content
+#let touying-progress(callback) = (
+ context {
+ if last-slide-counter.final().first() == 0 {
+ callback(1.0)
+ return
+ }
+ let ratio = calc.min(
+ 1.0,
+ slide-counter.get().first() / last-slide-counter.final().first(),
+ )
+ callback(ratio)
+ }
+)
+
+// slide note state
+#let slide-note-state = state("touying-slide-note-state", none)
+#let current-slide-note = context slide-note-state.get()
+
+// state to store the location of the newslide for handling frozen states
+#let loc-prior-newslide = state("touying-loc-prior-newslide", none)
+
+
+/// Remove leading and trailing empty elements from an array of content.
+///
+/// Example: `trim(([], [ ], parbreak(), linebreak(), [a], [ ], [b], [c], linebreak(), parbreak(), [ ], [ ]))` returns `([a], [ ], [b], [c])`
+///
+/// - arr (array): The array of content to trim.
+///
+/// - empty-contents (array): An array of content elements considered empty. Default is `([], [ ], parbreak(), linebreak())`.
+///
+/// -> array
+#let trim(arr, empty-contents: ([], [ ], parbreak(), linebreak())) = {
+ let i = 0
+ let j = arr.len() - 1
+ while i != arr.len() and arr.at(i) in empty-contents {
+ i += 1
+ }
+ while j != i - 1 and arr.at(j) in empty-contents {
+ j -= 1
+ }
+ arr.slice(i, j + 1)
+}
+
+
+/// Add a label to a content.
+///
+/// Example: `label-it("key", [a])` is equivalent to `[a <key>]`
+///
+/// - it (content): The content to label.
+///
+/// - label-name (str, label): The name of the label, or a label.
+///
+/// -> content
+#let label-it(it, label-name) = {
+ if type(label-name) == label {
+ [#it#label-name]
+ } else {
+ assert(type(label-name) == str, message: repr(label-name))
+ [#it#label(label-name)]
+ }
+}
+
+/// Reconstruct a content with a new body.
+///
+/// - body-name (str): The property name of the body field.
+///
+/// - labeled (bool): Indicates whether the label of the content should be preserved.
+///
+/// - named (bool): Indicates whether to pass fields as named arguments.
+///
+/// - it (content): The content to reconstruct.
+///
+/// - new-body (content): The new body you want to replace the old body with.
+///
+/// -> content
+#let reconstruct(
+ body-name: "body",
+ labeled: true,
+ named: false,
+ it,
+ ..new-body,
+) = {
+ let fields = it.fields()
+ let label = fields.remove("label", default: none)
+ let _ = fields.remove(body-name, default: none)
+ if named {
+ if label != none and labeled {
+ return [#(it.func())(..fields, ..new-body)#label]
+ } else {
+ return (it.func())(..fields, ..new-body)
+ }
+ } else {
+ if label != none and labeled {
+ return [#(it.func())(..fields.values(), ..new-body)#label]
+ } else {
+ return (it.func())(..fields.values(), ..new-body)
+ }
+ }
+}
+
+/// Reconstruct a table-like content with new children.
+///
+/// - named (bool): Whether to pass fields as named arguments. Default is `true`.
+///
+/// - labeled (bool): Whether to preserve the label of the content. Default is `true`.
+///
+/// - it (content): The content to reconstruct.
+///
+/// - new-children (array): The new children to replace the old children with.
+///
+/// -> content
+#let reconstruct-table-like(named: true, labeled: true, it, new-children) = {
+ reconstruct(
+ body-name: "children",
+ named: named,
+ labeled: labeled,
+ it,
+ ..new-children,
+ )
+}
+
+
+#let typst-builtin-sequence = [].func()
+
+/// Determine if a content is a sequence (i.e. created by concatenating content with `+` or implicit adjacency).
+///
+/// Example: `is-sequence([a])` returns `true`
+///
+/// - it (content): The content to check.
+///
+/// -> bool
+#let is-sequence(it) = {
+ type(it) == content and it.func() == typst-builtin-sequence
+}
+
+
+#let typst-builtin-styled = text(red)[].func()
+
+/// Determine if a content is styled (i.e. wrapped by Typst's internal styled element when `set` or `show` rules are applied).
+///
+/// Example: `is-styled(text(fill: red)[Red])` returns `true`
+///
+/// - it (content): The content to check.
+///
+/// -> bool
+#let is-styled(it) = {
+ type(it) == content and it.func() == typst-builtin-styled
+}
+
+
+#let typst-builtin-space = [ ].func()
+
+/// Determine if a content is a space (i.e. created by using whitespace in source code).
+///
+/// Example: `is-styled([ ])` returns `true`
+///
+/// - it (content): The content to check.
+///
+/// -> bool
+#let is-space(it) = {
+ type(it) == content and it.func() == typst-builtin-space
+}
+
+
+/// Reconstruct a styled content with a new body.
+///
+/// - it (content): The content to reconstruct.
+///
+/// - new-child (content): The new child you want to replace the old body with.
+///
+/// -> content
+#let reconstruct-styled(it, new-child) = {
+ typst-builtin-styled(new-child, it.styles)
+}
+
+
+/// Determine if a content is a `metadata(...)` element.
+///
+/// Example: `is-metadata(metadata((a: 1)))` returns `true`
+///
+/// - it (content): The content to check.
+///
+/// -> bool
+#let is-metadata(it) = {
+ type(it) == content and it.func() == metadata
+}
+
+
+/// Determine if a content is a metadata with a specific kind.
+///
+/// - it (content): The content to check.
+///
+/// - kind (str): The kind string to match.
+///
+/// -> bool
+#let is-kind(it, kind) = {
+ (
+ is-metadata(it)
+ and type(it.value) == dictionary
+ and it.value.at("kind", default: none) == kind
+ )
+}
+
+
+/// Determine if a content is a heading up to specific depth.
+///
+/// - it (content): The content to check.
+///
+/// - depth (int): Maximum heading depth to consider. Default is `9999`.
+///
+/// -> bool
+#let is-heading(it, depth: 9999) = {
+ type(it) == content and it.func() == heading and it.depth <= depth
+}
+
+
+/// Call a `self => {..}` function and return the result, or wrap plain content in `[]`.
+///
+/// - self (dictionary): The presentation context.
+///
+/// - it (content, function): The content to display, or a callback `self => content`.
+///
+/// -> content
+#let call-or-display(self, it) = {
+ if type(it) == function {
+ it = it(self)
+ }
+ return [#it]
+}
+
+/// recursively checks if `it` has a text in it
+///
+/// - it (content): the content to check
+/// - transparentize-table (bool): Whether to assume tables contain text. If `false` tables will get searched completely for available text.
+/// -> bool
+#let _contains-text(it, transparentize-table) = {
+ if type(it) != content {
+ return false
+ }
+ if it.func() in (text, math.equation) {
+ return true
+ }
+ if it.has("body") {
+ return _contains-text(it.body, transparentize-table)
+ }
+ if it.has("child") {
+ return _contains-text(it.child, transparentize-table)
+ }
+ if it.has("children") {
+ if it.func() == table {
+ return transparentize-table
+ }
+ for child in it.children {
+ if _contains-text(child, transparentize-table) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+/// Wrap a function with a `self` parameter to make it callable as a method.
+///
+/// Returns a new function of the form `(self: none, ..args) => fn(..args)`.
+///
+/// Example: `#let hide = method-wrapper(hide)` to get a `hide` method.
+///
+/// - fn (function): The function to wrap.
+///
+/// -> function
+#let method-wrapper(fn) = (self: none, ..args) => fn(..args)
+
+
+/// Extract all method functions from `self` and bind `self` as their first named argument.
+///
+/// Returns a dictionary of ready-to-call functions where the `self` argument has already been applied. Use destructuring to get individual methods.
+///
+/// Example: `#let (uncover, only) = utils.methods(self)` to get `uncover` and `only` methods.
+///
+/// - self (dictionary): The presentation context (must have a `methods` key containing a dictionary of functions).
+///
+/// -> dictionary
+#let methods(self) = {
+ assert(type(self) == dictionary, message: "self must be a dictionary")
+ assert(
+ "methods" in self and type(self.methods) == dictionary,
+ message: "self.methods must be a dictionary",
+ )
+ // Animation methods that manage their own subslide visibility.
+ // In callback-style slides the parser's pause/cover logic (driven by
+ // #waypoint jumps) would incorrectly hide method-resolved content based on
+ // source position. Wrapping the result in a fn-wrapper escapes pause zones
+ // (the parser always pushes fn-wrappers to `result`).
+ //
+ // The type check ensures non-content results (e.g. CeTZ draw-command arrays)
+ // are returned as-is so external packages keep working.
+ let animation-keys = (
+ "uncover",
+ "only",
+ "effect",
+ "alternatives",
+ "alternatives-match",
+ "alternatives-fn",
+ "alternatives-cases",
+ "item-by-item",
+ )
+ let methods = (:)
+ for key in self.methods.keys() {
+ if type(self.methods.at(key)) == function {
+ if key in animation-keys {
+ methods.insert(key, (..args) => {
+ let result = self.methods.at(key)(self: self, ..args)
+ if type(result) == content {
+ [#metadata((
+ kind: "touying-fn-wrapper",
+ fn: (self: none) => result,
+ args: arguments(),
+ last-subslide: none,
+ repetitions: none,
+ ))<touying-temporary-mark>]
+ } else {
+ result
+ }
+ })
+ } else {
+ methods.insert(key, (..args) => self.methods.at(key)(
+ self: self,
+ ..args,
+ ))
+ }
+ }
+ }
+ return methods
+}
+
+
+// -------------------------------------
+// Headings
+// -------------------------------------
+
+
+/// Capitalize a string.
+///
+/// - s (str): The string to convert.
+///
+/// -> str
+#let capitalize(s) = {
+ assert(type(s) == str, message: "s must be a string")
+ if s.len() == 0 {
+ return s
+ }
+ let lowercase = lower(s)
+ upper(lowercase.at(0)) + lowercase.slice(1)
+}
+
+
+/// Convert a string into title case.
+///
+/// - s (str): The string to convert.
+///
+/// -> str
+#let titlecase(s) = {
+ assert(type(s) == str, message: "s must be a string")
+ if s.len() == 0 {
+ return s
+ }
+ s.split(" ").map(capitalize).join(" ")
+}
+
+
+/// Convert a heading with label to a short display form.
+///
+/// If the heading has a special Touying label (e.g. `touying:hidden`), returns the heading body as-is.
+/// If the heading has a user label (e.g. `section:my-section`), strips the namespace prefix and applies title case via `convert-label-to-short-heading`.
+///
+/// - it (content): The heading content element.
+///
+/// -> content
+#let short-heading(self: none, it) = {
+ if it == none {
+ return
+ }
+ let convert-label-to-short-heading = if (
+ type(self) == dictionary
+ and "methods" in self
+ and "convert-label-to-short-heading" in self.methods
+ ) {
+ self.methods.convert-label-to-short-heading
+ } else {
+ (self: none, lbl) => titlecase(
+ lbl.replace(regex("^[^:]*:"), "").replace("_", " ").replace("-", " "),
+ )
+ }
+ convert-label-to-short-heading = convert-label-to-short-heading.with(
+ self: self,
+ )
+ assert(
+ type(it) == content and it.func() == heading,
+ message: "it must be a heading",
+ )
+ if not it.has("label") {
+ return it.body
+ }
+ let lbl = str(it.label)
+ if (
+ lbl
+ in (
+ "touying:hidden",
+ "touying:skip",
+ "touying:unnumbered",
+ "touying:unoutlined",
+ "touying:unbookmarked",
+ )
+ ) {
+ return it.body
+ }
+ return convert-label-to-short-heading(lbl)
+}
+
+
+/// Get the current heading on or before the current page.
+///
+/// - level (int, auto): The level of the heading. If `level` is `auto`, it will return the last heading on or before the current page. If `level` is a number, it will return the last heading on or before the current page with the same level.
+///
+/// - hierachical (bool): Whether to return the heading hierarchically. If `true`, returns the last heading according to the hierarchical structure. If `false`, returns the last heading on or before the current page with the same level.
+///
+/// - depth (int): The maximum depth of the heading to search. Usually, it should be set as slide-level.
+///
+/// -> content
+#let current-heading(level: auto, hierachical: true, depth: 9999) = {
+ let current-page = here().page()
+ if not hierachical and level != auto {
+ let headings = query(heading).filter(h => (
+ h.location().page() <= current-page
+ and h.level <= depth
+ and h.level == level
+ ))
+ return headings.at(-1, default: none)
+ }
+ let headings = query(heading).filter(h => (
+ h.location().page() <= current-page and h.level <= depth
+ ))
+ if headings == () {
+ return
+ }
+ if level == auto {
+ return headings.last()
+ }
+ let current-level = headings.last().level
+ let current-heading = headings.pop()
+ while headings.len() > 0 and level < current-level {
+ current-level = headings.last().level
+ current-heading = headings.pop()
+ }
+ if level == current-level {
+ return current-heading
+ }
+}
+
+#let reconstruct-heading(it, new-body, ..args) = {
+ assert(
+ type(it) == content and it.func() == heading,
+ message: "it must be a heading",
+ )
+ let heading-args = (
+ numbering: it.numbering,
+ bookmarked: it.bookmarked,
+ depth: it.depth,
+ offset: it.offset,
+ outlined: it.outlined,
+ hanging-indent: it.hanging-indent,
+ supplement: it.supplement,
+ )
+ if args != (:) { heading-args = merge-dicts(heading-args, args.named()) }
+
+ if it.has("label") {
+ return [#heading(
+ ..heading-args,
+ new-body,
+ )#it.label]
+ }
+ heading(
+ ..heading-args,
+ new-body,
+ )
+}
+
+
+/// Display the current heading on the page.
+///
+/// - level (int, auto): The level of the heading. If `level` is `auto`, it will return the last heading on or before the current page. If `level` is a number, it will return the last heading on or before the current page with the same level.
+///
+/// - numbered (bool): Whether to display the heading numbering. Default is `true`.
+///
+/// - hierachical (bool): Whether to return the heading hierarchically. If `true`, returns the last heading according to the hierarchical structure. If `false`, returns the last heading on or before the current page with the same level.
+///
+/// - depth (int): The maximum depth of the heading to search. Usually, it should be set as slide-level.
+///
+/// - setting (function): The setting of the heading. Default is `body => body`.
+///
+/// - style (function): The style of the heading. If `style` is a function, it will use the function to style the heading. For example, `style: current-heading => current-heading.body`.
+///
+/// If you set it to `style: auto`, it will be controlled by `show heading` rules.
+///
+/// -> content
+#let display-current-heading(
+ self: none,
+ level: auto,
+ hierachical: true,
+ depth: 9999,
+ style: (setting: body => body, numbered: true, current-heading) => setting({
+ if numbered and current-heading.numbering != none {
+ (
+ std.numbering(
+ current-heading.numbering,
+ ..counter(heading).at(current-heading.location()),
+ )
+ + h(.3em)
+ )
+ }
+ current-heading.body
+ }),
+ ..setting-args,
+) = (
+ context {
+ let current-heading = current-heading(
+ level: level,
+ hierachical: hierachical,
+ depth: depth,
+ )
+ if current-heading != none {
+ if style == none {
+ return current-heading
+ }
+
+ let setting-args-named = setting-args.named()
+ let _style = style
+ if style == auto {
+ _style = (
+ setting: body => body,
+ numbered: true,
+ current-heading,
+ ) => setting({
+ if numbered and current-heading.numbering != none {
+ (
+ std.numbering(
+ current-heading.numbering,
+ ..counter(heading).at(current-heading.location()),
+ )
+ + h(.3em)
+ )
+ }
+ current-heading.body
+ })
+
+ let current-level = current-heading.level
+ if current-level == 1 {
+ setting-args-named = merge-dicts(setting-args-named, (
+ setting: text.with(.715em),
+ ))
+ } //else do nothing
+ }
+ _style(..setting-args-named, ..setting-args.pos(), current-heading)
+ }
+ }
+)
+
+
+/// Display the current heading number on the page.
+///
+/// - level (int, auto): The level of the heading. If `level` is `auto`, it will return the last heading on or before the current page. If `level` is a number, it will return the last heading on or before the current page with the same level.
+///
+/// - numbering (str, auto): The numbering of the heading. If `auto`, uses the heading's own numbering. If a string, uses that as the numbering pattern.
+///
+/// - hierachical (bool): Whether to return the heading hierarchically. If `true`, returns the last heading according to the hierarchical structure. If `false`, returns the last heading on or before the current page with the same level.
+///
+/// - depth (int): The maximum depth of the heading to search. Usually, it should be set as slide-level.
+///
+/// -> content
+#let display-current-heading-number(
+ level: auto,
+ numbering: auto,
+ hierachical: true,
+ depth: 9999,
+) = (
+ context {
+ let current-heading = current-heading(
+ level: level,
+ hierachical: hierachical,
+ depth: depth,
+ )
+ if (
+ current-heading != none
+ and numbering == auto
+ and current-heading.numbering != none
+ ) {
+ std.numbering(
+ current-heading.numbering,
+ ..counter(heading).at(current-heading.location()),
+ )
+ } else if current-heading != none and numbering != auto {
+ std.numbering(
+ numbering,
+ ..counter(heading).at(current-heading.location()),
+ )
+ }
+ }
+)
+
+
+/// Display the current short heading on the page.
+///
+/// - level (int, auto): The level of the heading. If `level` is `auto`, it will return the last heading on or before the current page. If `level` is a number, it will return the last heading on or before the current page with the same level.
+///
+/// - hierachical (bool): Whether to return the heading hierarchically. If `true`, returns the last heading according to the hierarchical structure. If `false`, returns the last heading on or before the current page with the same level.
+///
+/// - depth (int): The maximum depth of the heading to search. Usually, it should be set as slide-level.
+///
+/// - style (function): The style of the heading. If `style` is a function, it will use the function to style the heading. For example, `style: (self: none, current-heading) => utils.short-heading(self: self, current-heading)`.
+///
+/// -> content
+#let display-current-short-heading(
+ self: none,
+ level: auto,
+ hierachical: true,
+ depth: 9999,
+ setting: body => body,
+ style: (self: none, current-heading) => short-heading(
+ self: self,
+ current-heading,
+ ),
+ ..setting-args,
+) = (
+ context {
+ let current-heading = current-heading(
+ level: level,
+ hierachical: hierachical,
+ depth: depth,
+ )
+ if current-heading != none {
+ if style == none {
+ current-heading
+ } else {
+ style(self: self, ..setting-args, current-heading)
+ }
+ }
+ }
+)
+
+/// Get the relationship of the current section a passed in outline entry. For past sections of another top-level section it returns -2, for past section of the current top-level section it returns -1. For the current section and children it returns 0, for future sections of the current top-level section it returns 1, and for future sections of another top-level section it returns 2.
+///
+/// Usage:
+/// ```typc
+/// #{// displays all top levels and all levels of the current top-level,
+/// // with future siblings and other top levels semi-transparent
+/// // and the current entry bold
+/// show outline.entry: it => {
+/// let relationship = utils.section-relationship(it)
+/// let current = utils.current-heading()
+/// let alpha = if relationship == -2 or relationship > 0 {40%} else {100%}
+/// let weight = if relationship == 0 and current.level == it.level { "bold" } else { "regular" }
+/// if it.level > 1 and calc.abs(relationship) > 1 {
+/// none
+/// // text(fill:red, it) // this will show all non-displayed entries in red.
+/// } else {
+/// text(fill:utils.update-alpha(text.fill, alpha), weight: weight, it)
+/// }
+/// }
+/// // if title is not none, it will create a new top-level heading which interferes with the computation
+/// outline(title:none)
+/// }
+/// ```
+///
+/// - current (content, none): The current heading to compare with. Default is `auto`, which uses `utils.current-heading()`.
+/// - it (content): The outline entry to compare with.
+///
+/// -> int
+#let section-relationship(current: auto, it) = {
+ if current == auto {
+ current = current-heading()
+ }
+ let current-top-heading = current-heading(depth: 1)
+ if current-top-heading == none {
+ warning(
+ "Found no current top-level heading when trying to compute section relationship. Falling back to the current heading. This might cause problems. Problematic heading: "
+ + repr(current.body),
+ )
+ current-top-heading = current
+ }
+ let next-top-heading = query(
+ selector(heading.where(depth: 1)).after(
+ inclusive: false,
+ current-top-heading.location(),
+ ),
+ ).at(0, default: none)
+ let next-heading = query(
+ //the next non-child section heading
+ selector(heading.where(depth: current.level)).after(
+ inclusive: false,
+ current.location(),
+ ),
+ ).at(0, default: none)
+ let this-top-loc = current-top-heading.location().page()
+ let this-loc = current.location().page()
+ let next-sibling-loc = if next-heading != none {
+ next-heading.location().page()
+ } else {
+ calc.inf
+ }
+ let next-top-loc = if next-top-heading != none {
+ next-top-heading.location().page()
+ } else {
+ calc.inf
+ }
+
+ let it-location = it.element.location().page()
+
+ if it-location < this-top-loc {
+ return -2
+ } else if it-location < this-loc {
+ return -1
+ } else if it-location < next-sibling-loc and it-location < next-top-loc {
+ return 0
+ } else if it-location < next-top-loc {
+ return 1
+ } else {
+ return 2
+ }
+}
+
+
+/// Display the date from `self.info.date` formatted with `self.datetime-format`.
+///
+/// Returns the date as a formatted string when `self.info.date` is a `datetime`, or returns it as-is when it is already `content`.
+///
+/// - self (dictionary): The presentation context (must have `self.info.date`).
+///
+/// -> content, str
+#let display-info-date(self) = {
+ assert("info" in self, message: "self must have an info field")
+ if type(self.info.date) == datetime {
+ self.info.date.display(self.at("datetime-format", default: auto))
+ } else {
+ self.info.date
+ }
+}
+
+
+/// Convert content to markup text, partly from
+/// [typst-examples-book](https://sitandr.github.io/typst-examples-book/book/typstonomicon/extract_markup_text.html).
+///
+/// - it (content, str): The content to convert.
+///
+/// - mode (str): The output mode: `"typ"` for Typst markup or `"md"` for Markdown.
+///
+/// - indent (int): The number of spaces to indent. Default is `0`.
+///
+/// -> str
+#let markup-text(it, mode: "typ", indent: 0) = {
+ assert(mode == "typ" or mode == "md", message: "mode must be 'typ' or 'md'")
+ let indent-markup-text = markup-text.with(mode: mode, indent: indent + 2)
+ let markup-text = markup-text.with(mode: mode, indent: indent)
+ if type(it) == str {
+ it
+ } else if type(it) == content {
+ if it.func() == raw {
+ if it.block {
+ (
+ "\n"
+ + indent * " "
+ + "```"
+ + it.lang
+ + it
+ .text
+ .split("\n")
+ .map(l => "\n" + indent * " " + l)
+ .sum(default: "")
+ + "\n"
+ + indent * " "
+ + "```"
+ )
+ } else {
+ "`" + it.text + "`"
+ }
+ } else if it == [ ] {
+ " "
+ } else if it.func() == enum.item {
+ "\n" + indent * " " + "+ " + indent-markup-text(it.body)
+ } else if it.func() == list.item {
+ "\n" + indent * " " + "- " + indent-markup-text(it.body)
+ } else if it.func() == terms.item {
+ (
+ "\n"
+ + indent * " "
+ + "/ "
+ + markup-text(it.term)
+ + ": "
+ + indent-markup-text(it.description)
+ )
+ } else if it.func() == linebreak {
+ "\n" + indent * " "
+ } else if it.func() == parbreak {
+ "\n\n" + indent * " "
+ } else if it.func() == strong {
+ if mode == "md" {
+ "**" + markup-text(it.body) + "**"
+ } else {
+ "*" + markup-text(it.body) + "*"
+ }
+ } else if it.func() == emph {
+ if mode == "md" {
+ "*" + markup-text(it.body) + "*"
+ } else {
+ "_" + markup-text(it.body) + "_"
+ }
+ } else if it.func() == link and type(it.dest) == str {
+ if mode == "md" {
+ "[" + markup-text(it.body) + "](" + it.dest + ")"
+ } else {
+ "#link(\"" + it.dest + "\")[" + markup-text(it.body) + "]"
+ }
+ } else if it.func() == heading {
+ if mode == "md" {
+ it.depth * "#" + " " + markup-text(it.body) + "\n"
+ } else {
+ it.depth * "=" + " " + markup-text(it.body) + "\n"
+ }
+ } else if it.has("children") {
+ it.children.map(markup-text).join()
+ } else if it.has("body") {
+ markup-text(it.body)
+ } else if it.has("text") {
+ if type(it.text) == str {
+ it.text
+ } else {
+ markup-text(it.text)
+ }
+ } else if it.func() == smartquote {
+ if it.double {
+ "\""
+ } else {
+ "'"
+ }
+ } else {
+ ""
+ }
+ } else {
+ repr(it)
+ }
+}
+
+// Code: HEIGHT/WIDTH FITTING and cover-with-rect
+// Attribution: This file is based on the code from https://github.com/andreasKroepelin/polylux/pull/91
+// Author: ntjess
+
+#let _size-to-pt(size, container-dimension) = {
+ let to-convert = size
+ if type(size) == fraction {
+ let fr = repr(size * 1000000) //avoid capped precision
+ to-convert = float(fr.slice(0, fr.len() - 2)) / 1000000
+ }
+ if type(to-convert) in (int, float, ratio) {
+ //nice just a multiplication
+ to-convert = container-dimension * to-convert
+ } else {
+ to-convert = measure(v(to-convert)).height //get in pt if em
+ }
+ to-convert
+}
+
+#let _limit-content-width(width: none, body, container-size) = {
+ let mutable-width = width
+ if width == none {
+ mutable-width = calc.min(container-size.width, measure(body).width)
+ } else {
+ mutable-width = _size-to-pt(width, container-size.width)
+ }
+ box(width: mutable-width, body)
+}
+
+
+/// Fit content to specified/remaining height.
+///
+/// Example: `#utils.fit-to-height[BIG]`
+/// - height (length, fraction, relative): The height to fit the content to. For example, `height: 50%` will fit the content to half of the slide height. If given as a fraction, it will be based on the available height after everything else is evaluated, similar to how fractional lengths behave for table column widths. Default is `1fr` which means to fit the content to the full available rest height.
+///
+/// - width (length, fraction, relative): Will determine the width of the content after scaling. So, if you want the scaled content to fill half of the slide width, you can use `width: 50%`.
+///
+/// - prescale-width (length, fraction, relative): Allows you to make Typst's layout assume that the given content is to be laid out in a container of a certain width before scaling. For example, you can use `prescale-width: 200%` assuming the slide's width is twice the original.
+///
+/// - grow (bool): Indicates whether the content should be scaled up if it is smaller than the available height. Default is `true`.
+///
+/// - shrink (bool): Indicates whether the content should be scaled down if it is larger than the available height. Default is `true`.
+///
+/// - reflow (bool): Whether to allow text reflow when scaling with auto width. Default is `true`. Only works when `width` is `auto` and the body contains text.
+///
+/// - force-height (bool): Whether to force the content to occupy the full height and not have it fill the available width. Only matters when `reflow` is `true` and `width` is auto. By default `false`. When text is reflowed, it makes sense to use as much width as possible and not force the content to be as tall as possible. Lines are naturally discrete and thus so are the possible scaling factors to fit the lines to the available height. Forcing the height may lead to the text not occupying the available width.
+///
+/// - body (content): The content to fit. If two positional arguments are given, this will be height instead.
+///
+/// - args (arguments): For convenience and compatibility with older versions, passing in height as a positional argument is still supported. If two positional arguments are given, the first one is the width and the second one is the body.
+///
+/// -> content
+#let fit-to-height(
+ height: 1fr,
+ width: auto,
+ prescale-width: none,
+ grow: true,
+ shrink: true,
+ reflow: true,
+ force-height: false,
+ body,
+ ..args,
+) = {
+ assert(
+ args.pos().len() <= 1,
+ message: "Only two positional arguments allowed, which will be interpreted as height and body.",
+ )
+ if args.pos().len() == 1 {
+ height = body
+ body = args.pos().at(0)
+ }
+ context {
+ let layout-content(
+ width: auto,
+ prescale-width: none,
+ grow: true,
+ shrink: true,
+ height,
+ body,
+ ) = layout(container-size => {
+ let available-height = 0pt
+ if type(height) == fraction {
+ available-height = container-size.height
+ } else {
+ available-height = _size-to-pt(height, container-size.height)
+ }
+ // Provide a sensible initial width, which will define initial scale parameters.
+ // Note this is different from the post-scale width, which is a limiting factor
+ // on the allowable scaling ratio
+ let boxed-content = _limit-content-width(
+ width: prescale-width,
+ body,
+ container-size,
+ )
+
+ //get size of the content when boxed to the prescale width, which is the initial size before scaling, may be different from the container-width
+ let size = measure(boxed-content)
+ if size.height == 0pt or size.width == 0pt {
+ return body
+ }
+ let h-ratio = available-height / size.height
+
+ // post-scaling width
+ let mutable-width = width
+ if width == none or width == auto {
+ mutable-width = container-size.width
+ }
+ mutable-width = _size-to-pt(mutable-width, container-size.width)
+
+ let w-ratio = mutable-width / size.width
+ let ratio = calc.min(h-ratio, w-ratio) * 100%
+
+ if width == auto and reflow and _contains-text(body, false) {
+ //height is good rn, but width may be too small.
+ // get the current ratio of used/available width and scale such that we fill it. use sqrt trick to allow good flow.
+ // then height may again be slightly too small. repeat that.
+
+ let adjust-width(ratio, body, boxed-content, size) = {
+ let w-ratio = (
+ measure(scale(
+ ratio,
+ boxed-content,
+ origin: top + left,
+ reflow: true,
+ )).width
+ / size.width
+ )
+
+ let _boxed-content = block(
+ width: size.width / calc.sqrt(w-ratio), //increase width by sqrt of w-ratio
+ body,
+ )
+ ratio = calc.sqrt(w-ratio) * 100%
+ return (ratio, _boxed-content)
+ }
+
+ let adjust-height(ratio, body, boxed-content, size) = {
+ let h-ratio = (
+ measure(scale(
+ ratio,
+ boxed-content,
+ origin: top + left,
+ reflow: true,
+ )).height
+ / size.height
+ )
+
+ let _boxed-content = block(
+ width: size.width / float(ratio) * calc.sqrt(h-ratio), //reduce width by sqrt of h-ratio
+ body,
+ )
+ ratio *= calc.sqrt(1 / h-ratio)
+
+ h-ratio = (
+ measure(scale(
+ ratio,
+ _boxed-content,
+ origin: top + left,
+ reflow: true,
+ )).height
+ / size.height
+ )
+ ratio /= h-ratio
+
+ return (ratio, _boxed-content)
+ }
+
+ //improve iteratively, 2 seems enough.
+ for i in range(2) {
+ (ratio, boxed-content) = adjust-width(ratio, body, boxed-content, (
+ width: mutable-width,
+ height: available-height,
+ ))
+ (ratio, boxed-content) = adjust-height(ratio, body, boxed-content, (
+ width: mutable-width,
+ height: available-height,
+ ))
+ }
+ if not force-height {
+ //fix the width one last time linearly.
+ let scaled-width = measure(scale(
+ ratio,
+ boxed-content,
+ origin: top + left,
+ reflow: true,
+ )).width
+ let current-box-width = measure(boxed-content).width
+ boxed-content = box(
+ width: current-box-width * (mutable-width / scaled-width),
+ body,
+ )
+ }
+ }
+ if ((shrink and (ratio < 100%)) or (grow and (ratio > 100%))) {
+ scale(
+ ratio,
+ origin: top + left,
+ boxed-content,
+ reflow: true,
+ )
+ } else {
+ body
+ }
+ })
+ if type(height) == fraction {
+ block(
+ height: height,
+ layout-content(
+ width: width,
+ prescale-width: prescale-width,
+ grow: grow,
+ shrink: shrink,
+ height,
+ body,
+ ),
+ )
+ } else {
+ layout-content(
+ width: width,
+ prescale-width: prescale-width,
+ grow: grow,
+ shrink: shrink,
+ height,
+ body,
+ )
+ }
+ }
+}
+
+
+/// Fit content to specified width.
+///
+/// Example: `#utils.fit-to-width(100%)[BIG]`
+///
+/// - width (length, fraction, relative): The width to fit the content to. For example, `width: 50%` will fit the content to half of the slide width. If given as a fraction, it will be based on the available width after everything else is evaluated, similar to how fractional lengths behave for table column widths. Default is `1fr` which means to fit the content to the full available rest width.
+///
+/// - grow (bool): Indicates whether the content should be scaled up if it is smaller than the available width. Default is `true`.
+///
+/// - shrink (bool): Indicates whether the content should be scaled down if it is larger than the available width. Default is `true`.
+///
+/// - body (content): The content to fit. If two positional arguments are given, this will be width instead.
+///
+/// - args (arguments): For convenience and compatibility with older versions, passing in width as a positional argument is still supported. If two positional arguments are given, the first one is the width and the second one is the body.
+///
+/// -> content
+#let fit-to-width(width: 1fr, grow: true, shrink: true, body, ..args) = {
+ assert(
+ args.pos().len() <= 1,
+ message: "Only two positional arguments allowed, which will be interpreted as width and body.",
+ )
+ if args.pos().len() == 1 {
+ width = body
+ body = args.pos().at(0)
+ }
+
+ layout(layout-size => {
+ let content-width = measure(body).width
+ let width = _size-to-pt(width, layout-size.width)
+ if (
+ content-width != 0pt
+ and (
+ (shrink and (width < content-width))
+ or (grow and (width > content-width))
+ )
+ ) {
+ let ratio = width / content-width * 100%
+ scale(
+ // The box keeps content from prematurely wrapping
+ box(body, width: content-width),
+ origin: top + left,
+ x: ratio,
+ y: ratio,
+ reflow: true,
+ )
+ } else {
+ body
+ }
+ })
+}
+/// true for all typst content that is not inline.
+#let is-block(it) = {
+ // whenever sth is wrapped in a box it is automatically inlined.
+ //first get the variable stuff
+ if it.func() in (math.equation, raw, quote) {
+ return it.block
+ }
+ (
+ it.func()
+ in (
+ // model stuff
+ figure,
+ footnote.entry,
+ heading,
+ enum,
+ list,
+ terms,
+ par,
+ table,
+ title,
+ // text stuff already checked above, as can be both
+ // layout stuff
+ align,
+ block,
+ columns,
+ grid,
+ move,
+ pad,
+ place,
+ repeat,
+ rotate,
+ scale,
+ skew,
+ stack,
+ // visual stuff,
+ // (path is deprecated)
+ circle,
+ curve,
+ ellipse,
+ image,
+ line,
+ polygon,
+ rect,
+ square,
+ )
+ )
+}
+
+
+/// Cover content with a rectangle of a specified color. If you set the fill to the background color of the page, you can use this to create a semi-transparent overlay.
+///
+/// Example: `#utils.cover-with-rect(fill: "red")[Hidden]`
+///
+/// - cover-args (args): The arguments to pass to the rectangle.
+///
+/// - fill (color): The color to fill the rectangle with.
+///
+/// - inline (bool): Indicates whether the content should be displayed inline. Default is `auto`. It is determined based on content type, not inline for block content and inline for inline content.
+///
+/// - body (content): The content to cover.
+///
+/// -> content
+#let cover-with-rect(
+ self: none,
+ ..cover-args,
+ fill: auto,
+ inline: auto,
+ is-first: false,
+ body,
+) = {
+ if fill == auto {
+ panic(
+ "`auto` fill value is not supported until typst provides utilities to"
+ + " retrieve the current page background",
+ )
+ }
+ if type(fill) == str {
+ fill = rgb(fill)
+ }
+ if body == none {
+ return []
+ }
+ //handle all sorts of weird wrappers and space-like content
+ if body.func() == typst-builtin-styled {
+ // unwrap styled content and re-apply style after covering, to avoid the
+ // cover rect being wrapped in the styled element which can cause issues
+ // with certain styles (e.g. `set text-color(red)` would make the rect red)
+ return reconstruct-styled(
+ body,
+ cover-with-rect(
+ self: self,
+ ..cover-args,
+ fill: fill,
+ inline: inline,
+ body.child,
+ ),
+ )
+ }
+ //skip space/empty content
+ if body.func() in (parbreak, linebreak, typst-builtin-space, h, v) {
+ return body
+ }
+ // split up sequences to find actual content types
+ if body.func() == typst-builtin-sequence {
+ let bodies = body.children
+ return bodies
+ .map(b => {
+ cover-with-rect(
+ self: self,
+ ..cover-args,
+ fill: fill,
+ inline: inline,
+ b,
+ )
+ })
+ .sum(default: none)
+ }
+
+ if inline == auto {
+ inline = not is-block(body)
+ }
+
+ //debug colors keep these!!!
+ // fill = if body.func() == math.equation {
+ // rgb(0, 0, 255, 50%)
+ // } else if inline {
+ // rgb(0, 255, 0, 50%)
+ // } else {
+ // rgb(255, 0, 0, 50%)
+ // }
+ if inline and body.func() != math.equation {
+ // For inline content, use strike with a thick stroke to overlay a colored
+ // bar per line fragment. strike is line-break-aware: it renders per
+ // fragment during layout, so text wraps naturally (no rigid box).
+ // Measure body wrapped in par() to pick up show rules like
+ // `show par: set text(2em)` that affect the actual rendered size.
+ context {
+ // Measure a reference character wrapped in par() to pick up show rules
+ // like `show par: set text(2em)` that affect rendered text size.
+ let h = measure(par[Xg]).height
+ strike(
+ stroke: 1.6 * h + fill,
+ offset: -0.35 * h,
+ extent: 0.05 * h,
+ body,
+ )
+ //debug
+ // [#metadata(
+ // (func: "cover-with-rect/inline", pos: cover-args.pos(), named: cover-args.named(), body-func: body.func(), body-type: type(body), inline: inline, repr: repr(body), height: h),
+ // )<dbg>]
+ }
+ } else {
+ // For block content and inline math, measure and overlay with stack.
+ // Blocks don't need to line-wrap, and inline math is short enough that
+ // a single box won't cause overflow issues. strike doesn't work on math.
+ let to-display = layout(layout-size => {
+ context {
+ let new-body-func = if not body.func() in (align, math.equation) {
+ (body.func())
+ } else {
+ par
+ }
+
+ let m-body = body
+ if body.func() == align {
+ m-body = par(body.body)
+ }
+ let body-size = measure(m-body)
+ let bounding-width = calc.min(body-size.width, layout-size.width)
+ let wrapped-body-size = measure(box(m-body, width: bounding-width))
+
+ let named = cover-args.named()
+ if "width" not in named {
+ named.insert("width", wrapped-body-size.width)
+ }
+ if "height" not in named {
+ named.insert("height", wrapped-body-size.height)
+ }
+ if "outset" not in named {
+ // Inline math needs extra outset for superscripts/limits (top)
+ // and subscripts with descenders like g, y, p (bottom)
+ // let math-text-size = measure($X g$).height
+ let real-text-size = measure(new-body-func([Xg])).height
+ let top-outset = if inline { 0.35 * real-text-size } else {
+ 0.15 * real-text-size
+ }
+ let bottom-outset = if inline { 0.65 * real-text-size } else {
+ 0.45 * real-text-size
+ }
+ named.insert("outset", (top: top-outset, bottom: bottom-outset))
+ }
+ if not inline {
+ named.at("width") = layout-size.width
+ }
+
+ //calculate the extra required padding on top and bottom bc the non-covered text gives this to the layout, but wrapping text twice in a box kills it.
+ // this is required when you switch between non-text block to text block or the size changes, but somehow the spacing gets eaten when two large text blocks follow each other, then this is wrong. but we cannot detect that.
+ let extra = if (
+ (body.has("body") and body.body.func() == text)
+ or body.func() in (align, math.equation)
+ ) {
+ ((1.52 * measure(new-body-func([Xg])).height / text.size) - 1)
+ } else { 0 }
+ let extra-top = (
+ extra * if block.above == auto { par.spacing } else { block.above }
+ )
+ let extra-bottom = (
+ extra * if block.below == auto { par.spacing } else { block.below }
+ )
+
+ stack(
+ spacing: -wrapped-body-size.height,
+ if body.func() in (align, place) {
+ pad(top: extra-top, {
+ body
+ v(0pt)
+ }) //somehow the v element allows text to be the true height even when wrapped in pad.
+ } else {
+ pad(top: extra-top, body)
+ },
+ {
+ pad(
+ rect(
+ fill: fill,
+ ..named,
+ ..cover-args.pos(),
+ ),
+ bottom: extra-bottom,
+ )
+ },
+ )
+ //debug
+ // [#metadata(
+ // (
+ // func: "cover-with-rect",
+ // pos: cover-args.pos(),
+ // named: cover-args.named(),
+ // body-func: body.func(),
+ // body-type: type(body),
+ // inline: inline,
+ // repr: repr(body),
+ // height: wrapped-body-size.height,
+ // extra: extra,
+ // ),
+ // )<dbg>]
+ }
+ })
+ if inline {
+ //inline math comes here, as strike doesn't work on math content
+ box(to-display)
+ } else {
+ // Reconstruct the original block element around the covered content
+ // so it preserves native spacing (e.g. skew, figure, etc.).
+ to-display
+ }
+ }
+}
+
+/// Update the alpha channel of a color.
+///
+/// Example: `update-alpha(rgb("#ff0000"), 0.5)` returns a red color with 50% opacity.
+///
+/// - color (color): The color to update.
+///
+/// - alpha (ratio): The new alpha value as a percentage (e.g. `50%` for half-transparent).
+///
+/// -> color
+#let update-alpha(color, alpha) = (
+ color.opacify(100%).transparentize(100% - alpha)
+)
+
+
+/// Cover content with a semi-transparent rectangle matching the page background color.
+///
+/// Example: `config-methods(cover: utils.semi-transparent-cover)`
+///
+/// - alpha (ratio): The opacity of the covering rectangle (higher means more opaque/more hidden). Default is `85%`.
+///
+/// - body (content): The content to cover.
+///
+/// -> content
+#let semi-transparent-cover(self: none, alpha: 85%, ..cover-args, body) = {
+ cover-with-rect(
+ ..cover-args,
+ fill: update-alpha(
+ self.page.at("fill", default: rgb("#ffffff")),
+ alpha,
+ ),
+ body,
+ )
+}
+
+/// Cover content with a text-color-changing mechanism.
+///
+/// Example: `config-methods(cover: utils.color-changing-cover.with(color: gray))`
+///
+/// - color (color): The color to apply to text when covered. Default is `gray`.
+///
+/// - fallback-hide (func): The function to use to hide the content if it does not contain text. Default is typst's own `hide`. You may pass `none` to not hide non-text content. To hide content with a semi-transparent/color overlay, you can pass in `semi-transparent-cover`/`cover-with-rect.with(fill: ...)`.
+///
+/// - transparentize-table (bool): Whether to transparentize table content. Default is `false`.
+///
+/// - it (content): The content to cover.
+///
+/// - fallback-hide-args (dict): The named arguments to pass to the fallback hide function if the content does not contain text.
+///
+/// -> content
+#let color-changing-cover(
+ self: none,
+ color: gray,
+ fallback-hide: hide,
+ transparentize-table: false,
+ fallback-hide-args: (:),
+ it,
+) = {
+ let _fallback-hide = fallback-hide
+ if fallback-hide == none {
+ _fallback-hide = it => it
+ }
+ if not _contains-text(it, transparentize-table) {
+ if _fallback-hide in (semi-transparent-cover, cover-with-rect) {
+ _fallback-hide(self: self, it, ..fallback-hide-args)
+ } else {
+ _fallback-hide(it)
+ }
+ } else {
+ show regex(".+"): set text(color)
+ it
+ }
+}
+
+
+/// Cover content with an alpha-changing mechanism.
+///
+/// Example: `config-methods(cover: utils.alpha-changing-cover.with(alpha: 25%))`
+///
+/// - alpha (ratio): The opacity to apply to text colors when covered. Default is `25%`.
+///
+/// - fallback-hide (func): The function to use to hide the content if it does not contain text. Default is typst's own `hide`. You may pass `none` to not hide non-text content. To hide content with a semi-transparent/color overlay, you can pass in `semi-transparent-cover`/`cover-with-rect.with(fill: ...)`.
+///
+/// - transparentize-table (bool): Whether to transparentize table content. Default is `false`.
+///
+/// - it (content): The content to cover.
+///
+/// - fallback-hide-args (args): The arguments to pass to the fallback hide function if the content does not contain text.
+///
+/// -> content
+#let alpha-changing-cover(
+ self: none,
+ alpha: 25%,
+ fallback-hide: hide,
+ transparentize-table: false,
+ fallback-hide-args: (:),
+ it,
+) = context {
+ let _fallback-hide = fallback-hide
+ if fallback-hide == none {
+ _fallback-hide = it => it
+ }
+
+ if not _contains-text(it, transparentize-table) {
+ if _fallback-hide in (semi-transparent-cover, cover-with-rect) {
+ _fallback-hide(self: self, it, ..fallback-hide-args)
+ } else {
+ _fallback-hide(it)
+ }
+ } else {
+ show regex(".+"): el => context {
+ text(update-alpha(text.fill, alpha), el)
+ }
+ it
+ }
+}
+
+
+/// Applies the theme's primary color to text content. Used as the default `alert` method.
+///
+/// Example: `config-methods(alert: utils.alert-with-primary-color)`
+///
+/// -> content
+#let alert-with-primary-color(self: none, body) = text(
+ fill: self.colors.primary,
+ body,
+)
+
+
+/// Apply alert styling to content using the theme's alert method. Equivalent to `(self.methods.alert)(self: self, body)`.
+///
+/// -> content
+#let alert(self: none, body) = (self.methods.alert)(self: self, body)
+
+
+// Code: check visible subslides and dynamic control
+// Attribution: This file is based on the code from https://github.com/andreasKroepelin/polylux/blob/main/logic.typ
+// Author: Andreas Kröpelin
+
+#let _parse-subslide-indices(s) = {
+ let parts = s.split(",").map(p => p.trim())
+ let parse-part(part) = {
+ let match-until = part.match(regex("^-([[:digit:]]+)$"))
+ let match-beginning = part.match(regex("^([[:digit:]]+)-$"))
+ let match-range = part.match(regex("^([[:digit:]]+)-([[:digit:]]+)$"))
+ let match-single = part.match(regex("^([[:digit:]]+)$"))
+ if match-until != none {
+ let parsed = int(match-until.captures.first())
+ // assert(parsed > 0, "parsed idx is non-positive")
+ (until: parsed)
+ } else if match-beginning != none {
+ let parsed = int(match-beginning.captures.first())
+ // assert(parsed > 0, "parsed idx is non-positive")
+ (beginning: parsed)
+ } else if match-range != none {
+ let parsed-first = int(match-range.captures.first())
+ let parsed-last = int(match-range.captures.last())
+ // assert(parsed-first > 0, "parsed idx is non-positive")
+ // assert(parsed-last > 0, "parsed idx is non-positive")
+ (beginning: parsed-first, until: parsed-last)
+ } else if match-single != none {
+ let parsed = int(match-single.captures.first())
+ // assert(parsed > 0, "parsed idx is non-positive")
+ parsed
+ } else {
+ panic("failed to parse visible slide idx:" + part)
+ }
+ }
+ parts.map(parse-part)
+}
+
+
+/// Check if a subslide index is visible given a visibility specification.
+///
+/// Example: `check-visible(3, "2-")` returns `true`
+///
+/// - idx (int): The current subslide index.
+///
+/// - visible-subslides (int, array, str): Specifies which subslides are visible.
+///
+/// Supported formats:
+///
+/// - A single integer, e.g. `3` — only subslide 3.
+/// - An array, e.g. `(1, 2, 4)` — equivalent to `"1, 2, 4"`.
+/// - A string with ranges, e.g. `"-2, 4, 6-8, 10-"` — subslides 1, 2, 4, 6, 7, 8, 10, and all after 10.
+///
+/// -> bool
+#let check-visible(idx, visible-subslides) = {
+ if type(visible-subslides) == int {
+ idx == visible-subslides
+ } else if type(visible-subslides) == array {
+ visible-subslides.any(s => check-visible(idx, s))
+ } else if type(visible-subslides) == str {
+ if visible-subslides.starts-with("!") {
+ // Negation: "!2-4" means everything except subslides 2-4
+ not check-visible(idx, visible-subslides.slice(1))
+ } else {
+ let parts = _parse-subslide-indices(visible-subslides)
+ check-visible(idx, parts)
+ }
+ } else if (
+ type(visible-subslides) == content and visible-subslides.has("text")
+ ) {
+ let parts = _parse-subslide-indices(visible-subslides.text)
+ check-visible(idx, parts)
+ } else if type(visible-subslides) == dictionary {
+ let kind = visible-subslides.at("kind", default: none)
+ if kind == "not" {
+ // Negation: visible everywhere except where inner is visible.
+ not check-visible(idx, visible-subslides.inner)
+ } else {
+ let lower-okay = if "beginning" in visible-subslides {
+ visible-subslides.beginning <= idx
+ } else {
+ true
+ }
+
+ let upper-okay = if "until" in visible-subslides {
+ visible-subslides.until >= idx
+ } else {
+ true
+ }
+
+ lower-okay and upper-okay
+ }
+ } else {
+ panic(
+ "you may only provide a single integer, an array of integers, or a string, got:"
+ + repr(visible-subslides),
+ )
+ }
+}
+
+
+/// Look up a waypoint label (with hierarchical prefix matching).
+///
+/// When looking up `<top>`, this also matches any child labels like
+/// `<top:sub>`, `<top:sub:deep>`, etc. The returned range spans from
+/// the earliest `first` to the latest `last` across all matches.
+///
+/// Returns `(first: int, last: int)` or `none` when the label is unknown.
+#let _lookup-waypoint-range(waypoints, lbl-str) = {
+ let prefix = lbl-str + ":"
+ let matches = waypoints
+ .pairs()
+ .filter(p => p.at(0) == lbl-str or p.at(0).starts-with(prefix))
+ if matches.len() > 0 {
+ let first = calc.min(..matches.map(p => p.at(1).first))
+ let last = calc.max(..matches.map(p => p.at(1).last))
+ (first: first, last: last)
+ } else {
+ none
+ }
+}
+
+
+/// Resolve a (possibly shifted) waypoint reference to a concrete label string.
+///
+/// Handles nested `prev-wp` / `next-wp` chains by walking to adjacent
+/// waypoints in subslide order. Returns `none` during a waypoint pre-pass
+/// when the label cannot be resolved.
+#let _resolve-waypoint-label(waypoints, wp, prepass: false) = {
+ if type(wp) == str {
+ wp
+ } else if type(wp) == label {
+ str(wp)
+ } else if type(wp) == dictionary {
+ let kind = wp.at("kind", default: none)
+ if kind in ("touying-waypoint-prev", "touying-waypoint-next") {
+ let base = _resolve-waypoint-label(waypoints, wp.inner, prepass: prepass)
+ if base == none { return none }
+ // Build sorted label list by first-subslide
+ let sorted = waypoints.pairs().sorted(key: p => p.at(1).first)
+ let labels = sorted.map(p => p.at(0))
+ let idx = labels.position(l => l == base)
+ // If no exact match, try hierarchical prefix match (e.g. <parent>
+ // when only <parent:a>, <parent:b> exist). Directional: next-wp
+ // anchors to the last child (to skip past the group), prev-wp
+ // anchors to the first child (to land before the group).
+ // When an exact parent label exists, it is used directly.
+ if idx == none {
+ let prefix = base + ":"
+ let children = labels
+ .enumerate()
+ .filter(p => p.at(1).starts-with(prefix))
+ if children.len() > 0 {
+ idx = if kind == "touying-waypoint-next" {
+ children.last().at(0)
+ } else {
+ children.first().at(0)
+ }
+ }
+ }
+ if idx == none {
+ if prepass { return none }
+ assert(false, message: "Unknown waypoint label: <" + base + ">")
+ }
+ let amount = wp.at("amount", default: 1)
+ let step = if kind == "touying-waypoint-prev" { -amount } else { amount }
+ let new-idx = idx + step
+ if new-idx < 0 or new-idx >= labels.len() {
+ if prepass { return none }
+ let dir = if kind == "touying-waypoint-prev" { "previous" } else {
+ "next"
+ }
+ assert(
+ false,
+ message: "No "
+ + dir
+ + " waypoint "
+ + str(amount)
+ + " step(s) from <"
+ + base
+ + ">",
+ )
+ }
+ labels.at(new-idx)
+ } else if kind in ("touying-waypoint-first", "touying-waypoint-last") {
+ // get-first / get-last — extract embedded label
+ wp.label
+ } else if kind in ("touying-waypoint-from", "touying-waypoint-until") {
+ // from-wp / until-wp — recurse into inner
+ _resolve-waypoint-label(waypoints, wp.inner, prepass: prepass)
+ } else {
+ if prepass { return none }
+ panic("Cannot resolve waypoint label from " + repr(wp))
+ }
+ } else {
+ if prepass { return none }
+ panic("Cannot resolve waypoint label from " + repr(wp))
+ }
+}
+
+
+/// Resolve waypoint labels in a visible-subslides specification.
+///
+/// Recursively replaces label references and waypoint marker dictionaries
+/// (`get-first`, `get-last`, `from-wp`, `until-wp`, `prev-wp`, `next-wp`) with
+/// their resolved subslide numbers / ranges using the waypoint mapping from
+/// `self.waypoints`.
+///
+/// Supports hierarchical labels: if `<part>` is not an exact match, all
+/// waypoints whose name starts with `part:` are combined into a single range.
+///
+/// When an array contains `from-wp` / `until-wp` markers the elements are
+/// combined into a bounded range (min of beginnings, max of ends):
+/// `(from-wp(<a>), until-wp(<b>))` yields the range from `<a>` to just before `<b>`.
+///
+/// - self (dictionary): The presentation context containing `waypoints`.
+///
+/// - visible-subslides: The visible-subslides specification to resolve.
+///
+/// -> int | str | array | dictionary
+#let resolve-waypoints(self, visible-subslides) = {
+ let waypoints = self.at("waypoints", default: (:))
+ let prepass = self.at("_waypoint-prepass", default: false)
+
+ // --- label ----------------------------------------------------------
+ if type(visible-subslides) == label {
+ let lbl = str(visible-subslides)
+ let range = _lookup-waypoint-range(waypoints, lbl)
+ if range == none {
+ if prepass { return (beginning: 1, until: 1) }
+ assert(false, message: "Unknown waypoint label: <" + lbl + ">")
+ }
+ (beginning: range.first, until: range.last)
+
+ // --- dictionary (waypoint markers) ----------------------------------
+ } else if type(visible-subslides) == dictionary {
+ let kind = visible-subslides.at("kind", default: none)
+
+ if kind == "touying-waypoint-first" {
+ let lbl = visible-subslides.label
+ let range = _lookup-waypoint-range(waypoints, lbl)
+ if range == none {
+ if prepass { return 1 }
+ assert(false, message: "Unknown waypoint label: <" + lbl + ">")
+ }
+ range.first
+ } else if kind == "touying-waypoint-last" {
+ let lbl = visible-subslides.label
+ let range = _lookup-waypoint-range(waypoints, lbl)
+ if range == none {
+ if prepass { return 1 }
+ assert(false, message: "Unknown waypoint label: <" + lbl + ">")
+ }
+ range.last
+ } else if kind == "touying-waypoint-from" {
+ let inner = visible-subslides.inner
+ let inner-kind = if type(inner) == dictionary {
+ inner.at("kind", default: none)
+ } else { none }
+ if inner-kind in ("touying-waypoint-first", "touying-waypoint-last") {
+ // Resolve get-first/get-last to a concrete subslide number
+ let resolved = resolve-waypoints(self, inner)
+ (beginning: resolved)
+ } else {
+ let lbl = _resolve-waypoint-label(
+ waypoints,
+ inner,
+ prepass: prepass,
+ )
+ if lbl == none {
+ if prepass { return (beginning: 1) }
+ assert(
+ false,
+ message: "Cannot resolve waypoint reference in from-wp()",
+ )
+ }
+ let range = _lookup-waypoint-range(waypoints, lbl)
+ if range == none {
+ if prepass { return (beginning: 1) }
+ assert(false, message: "Unknown waypoint label: <" + lbl + ">")
+ }
+ (beginning: range.first)
+ }
+ } else if kind == "touying-waypoint-until" {
+ let inner = visible-subslides.inner
+ let inner-kind = if type(inner) == dictionary {
+ inner.at("kind", default: none)
+ } else { none }
+ if inner-kind in ("touying-waypoint-first", "touying-waypoint-last") {
+ // Resolve get-first/get-last to a concrete subslide number
+ let resolved = resolve-waypoints(self, inner)
+ (until: resolved - 1)
+ } else {
+ let lbl = _resolve-waypoint-label(
+ waypoints,
+ inner,
+ prepass: prepass,
+ )
+ if lbl == none {
+ if prepass { return (until: 1) }
+ assert(
+ false,
+ message: "Cannot resolve waypoint reference in until-wp()",
+ )
+ }
+ let range = _lookup-waypoint-range(waypoints, lbl)
+ if range == none {
+ if prepass { return (until: 1) }
+ assert(false, message: "Unknown waypoint label: <" + lbl + ">")
+ }
+ (until: range.first - 1)
+ }
+ } else if kind in ("touying-waypoint-prev", "touying-waypoint-next") {
+ let lbl = _resolve-waypoint-label(
+ waypoints,
+ visible-subslides,
+ prepass: prepass,
+ )
+ if lbl == none {
+ if prepass { return (beginning: 1, until: 1) }
+ assert(
+ false,
+ message: "Cannot resolve shifted waypoint reference",
+ )
+ }
+ let range = _lookup-waypoint-range(waypoints, lbl)
+ if range == none {
+ if prepass { return (beginning: 1, until: 1) }
+ assert(false, message: "Unknown waypoint label: <" + lbl + ">")
+ }
+ (beginning: range.first, until: range.last)
+ } else if kind == "touying-waypoint-not" {
+ // Negate: resolve inner waypoint to a range, then wrap for check-visible.
+ let inner = visible-subslides.inner
+ let inner-kind = if type(inner) == dictionary {
+ inner.at("kind", default: none)
+ } else { none }
+ if inner-kind != none {
+ // Inner is another waypoint marker — resolve it first.
+ let resolved = resolve-waypoints(self, inner)
+ (kind: "not", inner: resolved)
+ } else {
+ // Inner is a plain label string — look up its range directly.
+ let lbl = _resolve-waypoint-label(waypoints, inner, prepass: prepass)
+ if lbl == none {
+ if prepass { return (kind: "not", inner: (beginning: 1, until: 1)) }
+ assert(
+ false,
+ message: "Cannot resolve waypoint reference in not-wp()",
+ )
+ }
+ let range = _lookup-waypoint-range(waypoints, lbl)
+ if range == none {
+ if prepass { return (kind: "not", inner: (beginning: 1, until: 1)) }
+ assert(false, message: "Unknown waypoint label: <" + lbl + ">")
+ }
+ (kind: "not", inner: (beginning: range.first, until: range.last))
+ }
+ } else {
+ visible-subslides
+ }
+
+ // --- array ----------------------------------------------------------
+ } else if type(visible-subslides) == array {
+ // If the array contains from/until range markers, span the full range.
+ let has-range-markers = visible-subslides.any(s => (
+ type(s) == dictionary
+ and s.at("kind", default: "")
+ in ("touying-waypoint-from", "touying-waypoint-until")
+ ))
+ if has-range-markers {
+ // Range construction: combine from/until markers into a single range.
+ // Multiple `from-wp`s → take earliest (min); multiple `until-wp`s → take latest (max).
+ // This spans the whole duration from the first `from-wp` to the last `until-wp`.
+ let resolved = visible-subslides.map(s => resolve-waypoints(self, s))
+ let beginning = none
+ let end = none
+ for r in resolved {
+ if type(r) == dictionary {
+ if "beginning" in r {
+ beginning = if beginning == none {
+ r.beginning
+ } else {
+ calc.min(beginning, r.beginning)
+ }
+ }
+ if "until" in r {
+ end = if end == none { r.until } else { calc.max(end, r.until) }
+ }
+ }
+ }
+ let result = (:)
+ if beginning != none {
+ result.insert("beginning", beginning)
+ }
+ if end != none {
+ result.insert("until", end)
+ }
+ result
+ } else {
+ visible-subslides.map(s => resolve-waypoints(self, s))
+ }
+
+ // --- pass-through (int, str, etc.) ----------------------------------
+ } else {
+ visible-subslides
+ }
+}
+
+
+#let last-required-subslide(visible-subslides) = {
+ if type(visible-subslides) == label {
+ // Labels are resolved at render time; the pauses that define waypoints
+ // already contribute to the repetitions count. Return 1 (not 0) so that
+ // the parser's two-pass escape hatch (next-last-subslide > 0) recognises
+ // that a fn-wrapper exists inside a nested sequence. A value of 1 never
+ // inflates the repeat count because repetitions is always >= 1.
+ 1
+ } else if type(visible-subslides) == int {
+ visible-subslides
+ } else if type(visible-subslides) == array {
+ calc.max(..visible-subslides.map(s => last-required-subslide(s)))
+ } else if type(visible-subslides) == str {
+ if visible-subslides.starts-with("!") {
+ // Negation cannot introduce new subslides, only use existing ones.
+ 0
+ } else {
+ let parts = _parse-subslide-indices(visible-subslides)
+ last-required-subslide(parts)
+ }
+ } else if type(visible-subslides) == dictionary {
+ let kind = visible-subslides.at("kind", default: none)
+ if (
+ kind
+ in (
+ "touying-waypoint-first",
+ "touying-waypoint-last",
+ "touying-waypoint-from",
+ "touying-waypoint-until",
+ "touying-waypoint-prev",
+ "touying-waypoint-next",
+ "touying-waypoint-not",
+ )
+ ) {
+ // Will be resolved at render time; pauses determine repeat count.
+ // Return 1 (not 0) so fn-wrapper escape hatch triggers (see label branch).
+ 1
+ } else {
+ let last = 0
+ if "beginning" in visible-subslides {
+ last = calc.max(last, visible-subslides.beginning)
+ }
+ if "until" in visible-subslides {
+ last = calc.max(last, visible-subslides.until)
+ }
+ last
+ }
+ } else {
+ panic(
+ "you may only provide `auto`, a single integer, an array of integers, a string or a waypoint label or marker",
+ )
+ }
+}
+
+/// Take effect in some subslides.
+///
+/// Example: `#effect(text.with(fill: red), "2-")[Something]` will display `[Something]` if the current slide is 2 or later.
+///
+/// You can also add an abbreviation by using `#let effect-red = effect.with(text.with(fill: red))` for your own effects.
+///
+/// - fn (function): The function that will be called in the subslide.
+/// Or you can use a method function like `(self: none) => { .. }`.
+///
+/// - visible-subslides (int, array, str): A single integer, an array of integers, or a string specifying the visible subslides.
+///
+/// Supported formats:
+///
+/// - A single integer, e.g. `3` — only subslide 3.
+/// - An array, e.g. `(1, 2, 4)` — equivalent to `"1, 2, 4"`.
+/// - A string with ranges, e.g. `"-2, 4, 6-8, 10-"` — subslides 1, 2, 4, 6, 7, 8, 10, and all after 10.
+///
+/// - cont (content): The content to display when the content is visible in the subslide.
+///
+/// - is-method (bool): Whether the function is a method function. Default is `false`.
+///
+/// -> content
+#let effect(
+ self: none,
+ fn,
+ visible-subslides,
+ cont,
+ is-method: false,
+ resolved-subslides: none,
+) = {
+ if is-method {
+ fn
+ } else {
+ let visible-subslides = if resolved-subslides != none {
+ resolved-subslides
+ } else { visible-subslides }
+ let visible-subslides = resolve-waypoints(self, visible-subslides)
+ if check-visible(self.subslide, visible-subslides) {
+ fn(cont)
+ } else {
+ cont
+ }
+ }
+}
+
+/// Uncover content in some subslides. Reserved space when hidden (like `#hide()`).
+///
+/// #example(
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+/// = Slide
+///
+/// #uncover("2-")[Only visible from subslide 2]
+/// )
+///
+/// - visible-subslides (int, array, str): A single integer, an array of integers, or a string specifying the visible subslides.
+///
+/// Supported formats:
+///
+/// - A single integer, e.g. `3` — only subslide 3.
+/// - An array, e.g. `(1, 2, 4)` — equivalent to `"1, 2, 4"`.
+/// - A string with ranges, e.g. `"-2, 4, 6-8, 10-"` — subslides 1, 2, 4, 6, 7, 8, 10, and all after 10.
+///
+/// - uncover-cont (content): The content to display when visible.
+///
+/// - cover-fn (function, auto): An optional cover function to use instead of the default cover method from the theme. Useful when using `uncover` inside external package integrations (e.g. `fletcher.hide` for fletcher diagrams).
+///
+/// -> content
+#let uncover(
+ self: none,
+ visible-subslides,
+ uncover-cont,
+ cover-fn: auto,
+ resolved-subslides: none,
+) = {
+ let visible-subslides = if resolved-subslides != none {
+ resolved-subslides
+ } else { visible-subslides }
+ let visible-subslides = resolve-waypoints(self, visible-subslides)
+ let cover = if cover-fn != auto { cover-fn } else {
+ self.methods.cover.with(self: self)
+ }
+ if check-visible(self.subslide, visible-subslides) {
+ uncover-cont
+ } else {
+ cover(uncover-cont)
+ }
+}
+
+
+/// Display content in some subslides only. No space is reserved when hidden.
+///
+/// #example(
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+/// = Slide
+///
+/// #only("2")[Only on subslide 2]
+/// )
+///
+/// - visible-subslides (int, array, str): A single integer, an array of integers, or a string specifying the visible subslides.
+///
+/// Supported formats:
+///
+/// - A single integer, e.g. `3` — only subslide 3.
+/// - An array, e.g. `(1, 2, 4)` — equivalent to `"1, 2, 4"`.
+/// - A string with ranges, e.g. `"-2, 4, 6-8, 10-"` — subslides 1, 2, 4, 6, 7, 8, 10, and all after 10.
+///
+/// - only-cont (content): The content to display when visible.
+///
+/// -> content
+#let only(
+ self: none,
+ visible-subslides,
+ only-cont,
+ resolved-subslides: none,
+) = {
+ let visible-subslides = if resolved-subslides != none {
+ resolved-subslides
+ } else { visible-subslides }
+ let visible-subslides = resolve-waypoints(self, visible-subslides)
+ if check-visible(self.subslide, visible-subslides) {
+ only-cont
+ }
+}
+
+
+/// Display content only in handout mode.
+/// Don't reserve space when hidden, content is completely not existing there.
+///
+/// Example:
+///
+/// ```typst
+/// #handout-only[This content is only visible in handout mode.]
+/// ```
+///
+/// - cont (content): The content to display in handout mode.
+///
+/// -> content
+#let handout-only(self: none, cont) = {
+ if self.handout {
+ cont
+ }
+}
+
+
+
+
+/// `#alternatives` has a couple of "cousins" that might be more convenient in some situations. The first one is `#alternatives-match` that has a name inspired by match-statements in many functional programming languages. The idea is that you give it a dictionary mapping from subslides to content:
+///
+/// Example:
+///
+/// ```typst
+/// #alternatives-match((
+/// "1, 3-5": [this text has the majority],
+/// "2, 6": [this is shown less often]
+/// ))
+/// ```
+///
+/// - subslides-contents (dictionary): A dictionary mapping from subslides to content.
+///
+/// - position (alignment): The position of the content. Default is `bottom + left`.
+///
+/// - stretch (bool): Whether to stretch all alternatives to the maximum width and height. Default is `false`.
+///
+/// Important: If you use a zero-length content like a context expression, you should set `stretch: false`.
+///
+/// -> content
+#let alternatives-match(
+ self: none,
+ subslides-contents,
+ position: bottom + left,
+ stretch: false,
+) = {
+ let subslides-contents = if type(subslides-contents) == dictionary {
+ subslides-contents.pairs()
+ } else {
+ subslides-contents
+ }
+
+ let contents = subslides-contents.map(it => it.last())
+
+ // Pre-resolve all subslide specs (handles waypoint labels, markers, etc.)
+ let resolved = subslides-contents.map(((s, _)) => resolve-waypoints(self, s))
+
+ if stretch {
+ context {
+ let sizes = contents.map(c => measure(c))
+ let max-width = calc.max(..sizes.map(sz => sz.width))
+ let max-height = calc.max(..sizes.map(sz => sz.height))
+ for (i, (_, content)) in subslides-contents.enumerate() {
+ // First-match-wins: skip if an earlier entry already matches this subslide
+ let earlier-match = resolved
+ .slice(0, i)
+ .any(
+ s => check-visible(self.subslide, s),
+ )
+ if not earlier-match and check-visible(self.subslide, resolved.at(i)) {
+ box(
+ width: max-width,
+ height: max-height,
+ align(position, content),
+ )
+ }
+ }
+ }
+ } else {
+ for (i, (_, content)) in subslides-contents.enumerate() {
+ // First-match-wins: skip if an earlier entry already matches this subslide
+ let earlier-match = resolved
+ .slice(0, i)
+ .any(
+ s => check-visible(self.subslide, s),
+ )
+ if not earlier-match and check-visible(self.subslide, resolved.at(i)) {
+ content
+ }
+ }
+ }
+}
+
+
+/// `#alternatives` is able to show contents sequentially in subslides.
+///
+/// Example: `#alternatives[Ann][Bob][Christopher]` will show "Ann" in the first subslide, "Bob" in the second subslide, and "Christopher" in the third subslide.
+///
+/// - start (int): The starting subslide number. Default is `1`.
+///
+/// - repeat-last (bool): Whether the last alternative should persist on all remaining subslides. Default is `true`.
+///
+/// - position (alignment): The alignment of alternatives within the reserved space. Default is `bottom + left`.
+///
+/// - stretch (bool): Whether to stretch all alternatives to the maximum width and height. Default is `false`.
+///
+/// Important: If you use a zero-length content like a context expression, you should set `stretch: false`.
+///
+/// -> content
+#let alternatives(
+ self: none,
+ start: 1,
+ repeat-last: true,
+ ..args,
+) = {
+ let contents = args.pos()
+ let kwargs = args.named()
+ let subslides = range(start, start + contents.len())
+ if repeat-last {
+ subslides.last() = (beginning: subslides.last())
+ }
+ alternatives-match(self: self, subslides.zip(contents), ..kwargs)
+}
+
+
+/// You can have very fine-grained control over the content depending on the current subslide by using #alternatives-fn. It accepts a function (hence the name) that maps the current subslide index to some content.
+///
+/// Example: `#alternatives-fn(start: 2, count: 7, subslide => { numbering("(i)", subslide) })`
+///
+/// - start (int): The starting subslide number. Default is `1`.
+///
+/// - end (int, none): The ending subslide number. Default is `none`.
+///
+/// - count (int, none): The number of subslides. Default is `none`.
+///
+/// - position (alignment): The alignment of alternatives within the reserved space. Default is `bottom + left`.
+///
+/// - stretch (bool): Whether to stretch all alternatives to the maximum width and height. Default is `false`.
+///
+/// Important: If you use a zero-length content like a context expression, you should set `stretch: false`.
+///
+/// -> content
+#let alternatives-fn(
+ self: none,
+ start: 1,
+ end: none,
+ count: none,
+ ..kwargs,
+ fn,
+) = {
+ let end = if end == none {
+ if count == none {
+ panic("You must specify either end or count.")
+ } else {
+ start + count
+ }
+ } else {
+ end
+ }
+
+ let subslides = range(start, end)
+ let contents = subslides.map(fn)
+ alternatives-match(self: self, subslides.zip(contents), ..kwargs.named())
+}
+
+
+/// You can use this function if you want to have one piece of content that changes only slightly depending of what "case" of subslides you are in.
+///
+/// Example:
+///
+/// ```typst
+/// #alternatives-cases(("1, 3", "2"), case => [
+/// #set text(fill: teal) if case == 1
+/// Some text
+/// ])
+/// ```
+///
+/// - cases (array): An array of strings that specify the subslides for each case.
+///
+/// - fn (function): A function that maps the case to content. The argument `case` is the index of the cases array you input.
+///
+/// - position (alignment): The alignment of alternatives within the reserved space. Default is `bottom + left`.
+///
+/// - stretch (bool): Whether to stretch all alternatives to the maximum width and height. Default is `false`.
+///
+/// Important: If you use a zero-length content like a context expression, you should set `stretch: false`.
+///
+/// -> content
+#let alternatives-cases(self: none, cases, fn, ..kwargs) = {
+ let idcs = range(cases.len())
+ let contents = idcs.map(fn)
+ alternatives-match(self: self, cases.zip(contents), ..kwargs.named())
+}
+
+/// Display list, enum, or terms items one by one with animation and styling.
+/// For more details see `utils.item-by-item`.
+///
+/// - start (int, label, str, dictionary): The starting subslide number or waypoint.
+/// - fn (function): A function that gets `(idx, it)` and returns the styled item content for the item at the relative index `idx` (may be negative if it was revealed already)
+/// - cont (content): The content containing the items to display.
+/// -> content
+#let item-by-item-fn(self: none, start: 1, fn, cont) = {
+ if fn == none {
+ fn = (idx, it) => it
+ }
+ let cover = self.methods.cover.with(self: self)
+ let item-funcs = (list.item, enum.item, terms.item)
+
+ // Resolve waypoint-based start to a concrete subslide number.
+ let start = if type(start) == int {
+ start
+ } else if (
+ type(start) == label
+ or (
+ type(start) == dictionary and start.at("kind", default: none) != none
+ )
+ ) {
+ let resolved = resolve-waypoints(self, start)
+ if type(resolved) == int {
+ resolved
+ } else if type(resolved) == dictionary and "beginning" in resolved {
+ resolved.beginning
+ } else if type(resolved) == dictionary and "first" in resolved {
+ resolved.first
+ } else {
+ 1
+ }
+ } else if type(start) == str {
+ let parts = _parse-subslide-indices(start)
+ if parts.len() == 1 and type(parts.first()) == int {
+ parts.first()
+ } else {
+ panic(
+ "item-by-item: `start` string must be a single number (e.g. \"3\"), "
+ + "not a range or multi-value spec. Got: \""
+ + start
+ + "\".",
+ )
+ }
+ } else {
+ panic(
+ "item-by-item: `start` must be an integer, a string with a single number, "
+ + "a waypoint label, or a single-position waypoint marker "
+ + "(get-first, get-last, prev-wp, next-wp). Got: "
+ + type(start),
+ )
+ }
+
+ if is-sequence(cont) {
+ // Markup list/enum/terms: items appear as list.item/enum.item/terms.item in a sequence
+ let item-count = 0
+ let result = ()
+ for child in cont.children {
+ if type(child) == content and child.func() in item-funcs {
+ if check-visible(self.subslide, (beginning: start + item-count)) {
+ result.push(fn(start + item-count - self.subslide, child))
+ } else {
+ result.push(fn(start + item-count - self.subslide, cover(child)))
+ }
+ item-count += 1
+ } else {
+ result.push(fn(start + item-count - self.subslide, child))
+ }
+ }
+ result.sum(default: [])
+ } else if cont.func() == list or cont.func() == enum {
+ // Programmatic list/enum container
+ let new-items = cont
+ .children
+ .enumerate()
+ .map(((idx, item)) => {
+ if check-visible(self.subslide, (beginning: start + idx)) {
+ fn(start + idx - self.subslide, item)
+ } else {
+ reconstruct(item, fn(start + idx - self.subslide, cover(item.body)))
+ }
+ })
+ reconstruct-table-like(cont, new-items)
+ } else if cont.func() == terms {
+ // Programmatic terms container
+ let new-items = cont
+ .children
+ .enumerate()
+ .map(((idx, item)) => {
+ if check-visible(self.subslide, (beginning: start + idx)) {
+ fn(start + idx - self.subslide, item)
+ } else {
+ terms.item(
+ fn(start + idx - self.subslide, cover(item.term)),
+ fn(start + idx - self.subslide, cover(item.description)),
+ )
+ }
+ })
+ reconstruct-table-like(cont, new-items)
+ } else {
+ // Fallback: show content as-is
+ cont
+ }
+}
+
+
+/// Display list, enum, or terms items one by one with animation.
+///
+/// Each item is revealed on a successive subslide. By default (`start: auto`),
+/// revealing is relative to the current pause position. `start` also accepts
+/// a waypoint label or marker to anchor the reveal sequence. From the anchor one additional item is revealed per subslide.
+///
+/// #example(
+/// >>> #let is-dark = sys.inputs.at("x-color-theme", default: none) == "dark";
+/// >>> #let text-color = if is-dark { std.white } else { std.black };
+/// >>> #show: simple-theme.with(
+/// >>> aspect-ratio: "16-9",
+/// >>> config-page(width: 320pt, height: 180pt),
+/// >>> config-colors(neutral-lightest: none, neutral-darkest: text-color),
+/// >>> )
+/// >>> #set text(.5em)
+/// <<< #show: simple-theme.with(aspect-ratio: "16-9")
+/// = Slide
+///
+/// #item-by-item[
+/// - first
+/// - second
+/// - third
+/// ]
+/// )
+///
+/// - start (auto | int | label | dictionary): The subslide on which the first\n/// item appears. Resolved from a waypoint when a label or marker is given.
+///
+/// - cont (content): The content containing a list, enum, or terms element.
+///
+/// -> content
+#let item-by-item(self: none, start: 1, cont) = {
+ item-by-item-fn(self: self, start: start, (idx, it) => it, cont)
+}
+
+
+/// Speaker notes are a way to add additional information to your slides that is not visible to the audience. This can be useful for providing additional context or reminders to yourself.
+///
+/// Multiple calls on the same slide are combined (accumulated), so all notes are shown together.
+///
+/// Example: `#speaker-note[This is a speaker note]`
+///
+/// - self (dictionary): The current presentation context.
+///
+/// - mode (str): The mode of the markup text, either `typ` or `md`. Default is `typ`.
+///
+/// - setting (function): A function that takes the note as input and returns a processed note.
+///
+/// - subslide (none, auto, int, array, str): Restricts the note to specific subslides, similar to `only`.
+/// - `none`: shown on all subslides.
+/// - `auto`: automatically determined from the current pause position (default when called via `#speaker-note`).
+/// - int, array, or string: shown only on the specified subslides.
+///
+/// - note (content): The content of the speaker note.
+///
+/// -> content
+#let speaker-note(
+ self: none,
+ mode: "typ",
+ setting: it => it,
+ subslide: none,
+ note,
+) = {
+ let show-only-notes = self.at("show-only-notes", default: false)
+ assert(
+ show-only-notes in (false, true),
+ message: "`show-only-notes` should be `false` or `true`",
+ )
+ let show-notes-on-second-screen = self.at(
+ "show-notes-on-second-screen",
+ default: none,
+ )
+ assert(
+ show-notes-on-second-screen in (none, bottom, right),
+ message: "`show-notes-on-second-screen` should be `none`, `bottom` or `right`",
+ )
+ let is-visible = (
+ subslide == none
+ or subslide == auto
+ or check-visible(self.subslide, subslide)
+ )
+ if is-visible {
+ if self.at("enable-pdfpc", default: true) {
+ let raw-text = if type(note) == content and note.has("text") {
+ note.text
+ } else {
+ markup-text(note, mode: mode).trim()
+ }
+ pdfpc.speaker-note(raw-text)
+ }
+ if show-only-notes or show-notes-on-second-screen != none {
+ slide-note-state.update(old => if old == none {
+ setting(note)
+ } else {
+ old + parbreak() + setting(note)
+ })
+ }
+ }
+}
+
+
+/// Convert an aspect ratio string to page configuration arguments.
+///
+/// For the built-in Typst presentation paper sizes ("16-9" and "4-3"), returns
+/// a `paper` key. For other ratios (e.g. "16-10", "3-2"), returns explicit
+/// `width` and `height` keys computed from the 16-9 base width (841.89pt).
+///
+/// Example:
+///
+/// ```typst
+/// config-page(..utils.page-args-from-aspect-ratio("16-10"))
+/// ```
+///
+/// - aspect-ratio (str): The aspect ratio string in `"W-H"` format where `W`
+/// and `H` are positive numbers. E.g., `"16-9"`, `"4-3"`, `"16-10"`.
+///
+/// -> dictionary
+#let page-args-from-aspect-ratio(aspect-ratio) = {
+ let known = ("16-9", "4-3")
+ if aspect-ratio in known {
+ (paper: "presentation-" + aspect-ratio)
+ } else {
+ let parts = aspect-ratio.split("-")
+ assert(
+ parts.len() == 2,
+ message: "Invalid aspect ratio \""
+ + aspect-ratio
+ + "\". Expected format: \"W-H\" with positive numbers, e.g. \"16-10\".",
+ )
+ let w-ratio = float(parts.at(0))
+ let h-ratio = float(parts.at(1))
+ assert(
+ w-ratio > 0 and h-ratio > 0,
+ message: "Invalid aspect ratio \""
+ + aspect-ratio
+ + "\": width and height must be positive numbers.",
+ )
+ let base-width = 841.89pt
+ (width: base-width, height: base-width * h-ratio / w-ratio)
+ }
+}
+
+
+/// Get the page width and height from the slide configuration.
+///
+/// Returns a tuple `(width, height)`. If the page has explicit `width`/`height`
+/// keys those are used directly; otherwise dimensions are derived from the
+/// `paper` key. The built-in Typst presentation paper sizes
+/// (`"presentation-16-9"` and `"presentation-4-3"`) are recognised; for any
+/// other paper name the 16-9 default dimensions (841.89pt × 473.56pt) are used
+/// as a fallback.
+///
+/// - self (dictionary): The current slide self dictionary.
+///
+/// -> array
+#let get-page-dimensions(self) = {
+ let page = self.page
+ let paper = page.at("paper", default: "presentation-16-9")
+ let (pw, ph) = if paper == "presentation-16-9" {
+ (841.89pt, 473.56pt)
+ } else if paper == "presentation-4-3" {
+ (793.7pt, 595.28pt)
+ } else {
+ // For explicit width/height pages the paper key may still be the default;
+ // the actual dimensions are read from the page dict below.
+ (841.89pt, 473.56pt)
+ }
+ let width = page.at("width", default: pw)
+ let height = page.at("height", default: ph)
+ (width, height)
+}
+
+
+/// Internationalized outline/table-of-contents title. Returns the appropriate word for the current document language (supports Arabic, Catalan, Czech, Danish, German, English, Spanish, Estonian, Finnish, Japanese, Russian, Traditional Chinese, and Simplified Chinese).
+///
+/// -> content
+#let i18n-outline-title = context {
+ let mapping = (
+ ar: "المحتويات",
+ ca: "Índex",
+ cs: "Obsah",
+ da: "Indhold",
+ de: "Inhalte",
+ en: "Outline",
+ es: "Índice",
+ et: "Sisukord",
+ fi: "Sisällys",
+ ja: "目次",
+ ru: "Содержание",
+ zh-TW: "目錄",
+ zh: "目录",
+ )
+ mapping.at(text.lang, default: mapping.en)
+}
--- /dev/null
+# generated by tytanic, do not edit
+
+**/diff/**
+**/out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.university: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: edge, node
+#import "@preview/numbly:0.1.0": numbly
+#import "@preview/theorion:0.6.0": *
+#import cosmos.clouds: *
+#show: show-theorion
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ // align: horizon,
+ // config-common(handout: true),
+ config-common(frozen-counters: (theorem-counter,)), // freeze theorem counter for animation
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= Animation
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+
+== Complex Animation
+
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+
+
+== Callback Style Animation
+
+#slide(
+ repeat: 3,
+ self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+ ],
+)
+
+
+== Math Equation Animation
+
+Equation with `pause`:
+
+$
+ f(x) & = pause x^2 + 2x + 1 \
+ & = pause (x + 1)^2 \
+$
+
+#meanwhile
+
+Here, #pause we have the expression of $f(x)$.
+
+#pause
+
+By factorizing, we can obtain this result.
+
+
+== CeTZ Animation
+
+CeTZ Animation in Touying:
+
+#cetz-canvas({
+ import cetz.draw: *
+
+ rect((0, 0), (5, 5))
+
+ (pause,)
+
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+
+ (pause,)
+
+ line((0, 0), (2.5, 2.5), name: "line")
+})
+
+
+== Fletcher Animation
+
+Fletcher Animation in Touying:
+
+#fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(
+ blue.lighten(80%),
+ blue,
+ center: (30%, 20%),
+ radius: 80%,
+ ),
+ spacing: 4em,
+ edge((-1, 0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0, 0), `reading`, radius: 2em),
+ edge((0, 0), (0, 0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1, 0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2, 0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0, 0), (2, 0), `close()`, "-|>", bend: -40deg),
+)
+
+
+= Theorems
+
+== Prime numbers
+
+#definition[
+ A natural number is called a #highlight[_prime number_] if it is greater
+ than 1 and cannot be written as the product of two smaller natural numbers.
+]
+#example[
+ The numbers $2$, $3$, and $17$ are prime.
+ @cor_largest_prime shows that this list is not exhaustive!
+]
+
+#theorem(title: "Euclid")[
+ There are infinitely many primes.
+]
+#pagebreak(weak: true)
+#proof[
+ Suppose to the contrary that $p_1, p_2, dots, p_n$ is a finite enumeration
+ of all primes. Set $P = p_1 p_2 dots p_n$. Since $P + 1$ is not in our list,
+ it cannot be prime. Thus, some prime factor $p_j$ divides $P + 1$. Since
+ $p_j$ also divides $P$, it must divide the difference $(P + 1) - P = 1$, a
+ contradiction.
+]
+
+#corollary[
+ There is no largest prime number.
+] <cor_largest_prime>
+#corollary[
+ There are infinitely many composite numbers.
+]
+
+#theorem[
+ There are arbitrarily long stretches of composite numbers.
+]
+
+#proof[
+ For any $n > 2$, consider $ n! + 2, quad n! + 3, quad ..., quad n! + n $
+]
+
+
+= Others
+
+== Multiple columns
+
+#cols[
+ First column.
+][
+ Second column.
+]
+
+== Multiple columns with equal height blocks
+
+#cols(columns: (1fr, 1fr), gutter: 1em)[
+ #emph-block[
+ First column with equal height: #lorem(10)
+ #lazy-v(1fr)
+ ]
+][
+ #emph-block[
+ Second column with equal height: : #lorem(15)
+ #lazy-v(1fr)
+ ]
+]
+
+
+== Multiple Pages
+
+#lorem(200)
+
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+#import "../../../lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Touying Fn Wrapper Raw
+
+== Animation with Alert
+
+Normal explanation.
+
+#pause
+
+#alert[This is an important point!]
+
+== With Uncover
+
+Normal explanation.
+
+#uncover("2-")[#alert[This is an important point with uncover!]]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: edge, node
+
+// cetz and fletcher bindings for touying
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+
+#show: simple-theme.with(
+ config-common(
+ show-hide-set-list-marker-none: true,
+ ),
+)
+
+= Animation
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+
+== Complex Animation
+
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times ✗][use `#alternatives` function ✓] for choosing one of the alternatives.
+
+
+== Callback Style Animation
+
+#slide(
+ repeat: 3,
+ self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times ✗][use `#alternatives` function ✓] for choosing one of the alternatives.
+ ],
+)
+
+
+== Math Equation Animation
+
+Equation with `pause`:
+
+$
+ f(x) & = pause x^2 + 2x + 1 \
+ & = pause (x + 1)^2 \
+$
+
+#meanwhile
+
+Here, #pause we have the expression of $f(x)$.
+
+#pause
+
+By factorizing, we can obtain this result.
+
+
+== CeTZ Animation
+
+#cetz-canvas({
+ import cetz.draw: *
+
+ rect((0, 0), (5, 5))
+
+ (pause,)
+
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+
+ (pause,)
+
+ line((0, 0), (2.5, 2.5), name: "line")
+})
+
+== only and uncover in Cetz
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ Cetz in Touying in subslide #self.subslide:
+
+ #cetz.canvas({
+ import cetz.draw: *
+ let uncover = uncover.with(cover-fn: hide.with(bounds: true))
+
+ rect((0, 0), (5, 5))
+
+ uncover("2-3", {
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+ })
+
+ only(3, line((0, 0), (2.5, 2.5), name: "line"))
+ })
+])
+
+
+== Fletcher Animation
+
+#fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(
+ blue.lighten(80%),
+ blue,
+ center: (30%, 20%),
+ radius: 80%,
+ ),
+ spacing: 4em,
+ edge((-1, 0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0, 0), `reading`, radius: 2em),
+ edge((0, 0), (0, 0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1, 0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2, 0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0, 0), (2, 0), `close()`, "-|>", bend: -40deg),
+)
+
+= Pause + Uncover Mixing
+
+== Pause + Uncover/Only Inline
+
+- On 1 #pause
+- On 2 #pause
+- #uncover("2-")[Uncover 2-] // was hidden even on subslide 2
+// - #{
+// uncover("2-")[
+// Uncover 2- in bare sequence
+// ]
+// }
+- #only(2)[Only 2] // was hidden even on subslide 2
+- On 3
+
+// #{
+// only(2)[Only 2 in bare sequence]
+// }
+
+== Pause + Alternatives Inline
+
+Text #pause then #alternatives[Alt 1][Alt 2] and more.
+
+= Jump
+
+== jump(n, relative: true) — relative stepping
+
+`#jump(1, relative: true)` is equivalent to `#pause`:
+
+A #jump(1, relative: true) B #jump(1, relative: true) C
+
+`#jump(2, relative: true)` skips an extra subslide:
+
+X #jump(2, relative: true) Z
+
+
+== jump(n) — absolute jumping
+
+`#jump(1)` is equivalent to `#meanwhile`:
+
+First #pause Second #jump(1) Always visible
+
+`#jump(3)` jumps to absolute subslide 3:
+
+Part A #pause Part B #jump(3) Part C
+
+
+== jump negative relative in CeTZ
+
+#cetz-canvas({
+ import cetz.draw: *
+
+ rect((0, 0), (5, 5))
+
+ (jump(1, relative: true),)
+
+ rect((0, 0), (2, 2))
+
+ (jump(-1, relative: true),)
+
+ circle((3.5, 3.5), radius: 1)
+})
+
+// some hard tests for weird possible behaviours.
+// == bare sequences test
+
+// // === Test 1: Bare sequence as direct sibling after fn-wrapper ===
+// Text before #pause
+// #uncover("2-")[Direct uncover]
+// #{
+// uncover("2-")[Bare sequence uncover — same last-subslide]
+// }
+// After all
+
+// // === Test 2: Two fn-wrappers inside a grid (table-like handler) ===
+// Text before #pause
+// #grid(columns: (1fr, 1fr),
+// uncover("2-")[Grid col 1],
+// only(2)[Grid col 2 only],
+// )
+// After grid
+//
+// #pause
+// After grid pause
+
+// // === Test 3: fn-wrapper inside columns ===
+// Text before #pause
+// #columns(2)[
+// #uncover("2-")[In columns]
+// ]
+// After columns
+
+// // === Test 4: fn-wrapper inside place ===
+// Text before #pause
+// #place(top + right, uncover("2-")[Placed uncover])
+// After place
+
+// // === Test 5: fn-wrapper inside rotate ===
+// Text before #pause
+// #rotate(5deg, uncover("2-")[Rotated uncover])
+// After rotate
+
+// // === Test 6: Nested — fn-wrapper in bare sequence inside columns ===
+// Text before #pause
+// #columns(2)[
+// #{
+// uncover("2-")[Nested bare seq in columns]
+// }
+// ]
+// After nested
+
+// // === Test 7: only() as direct top-level content after pause + uncover ===
+// Text before #pause
+// #uncover("2-")[Direct uncover]
+// #only(2)[Direct only — same last-subslide as uncover]
+// After both
+
+// // === Test 8: double grid
+// Text #pause
+// #grid(columns: 1, uncover("1-")[First grid — always visible])
+// #grid(columns: 1, uncover("1-")[Second grid — always visible])
+// After
+
+
+// // === Test 9: double bare sequence, with nested bare sequence
+// Text #pause
+// #{
+// uncover("1-")[First bare seq — always visible]
+// }
+// #{
+// uncover("1-")[Second bare seq — always visible]
+// {
+// uncover("2-")[Nested bare seq — from 2 onward]
+// }
+// }
+// After
+
+
+// == Nasty: Deep nesting
+// First #pause
+// #box(stroke:1pt)[
+// #strong[
+// #uncover("2-")[Deep nested uncover]
+// ]
+// #pause
+// #only(3)[Only 3 in box]
+// ]
+// #columns(2)[
+// #only("2-")[Col only 2-]
+// #pause
+// Final column text
+// ]
+// Last line
+
+// == Nasty: item-by-item + containers
+// #item-by-item[
+// - One
+// - Two
+// - Three
+// ]
+// #pause
+// #grid(columns: (1fr, 1fr),
+// only(5)[Grid only-5],
+// uncover("4-")[Grid uncover 4-],
+// )
+// #rotate(3deg, uncover("1-")[Rotated always])
+// #box[#only(6)[Box only-6]]\
+// Done at 4.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Main Content
+
+== Introduction
+
+This is the main content of the presentation.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#let bib = bytes(
+ "@book{dirac,
+ title={The Principles of Quantum Mechanics},
+ author={Paul Adrien Maurice Dirac},
+ series={International series of monographs on physics},
+ year={1981},
+ publisher={Clarendon Press},
+ }",
+)
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ config-common(show-bibliography-as-footnote: bibliography(bib)),
+)
+
+= Title
+
+== First Slide
+
+Hello, Touying! @dirac
+
+== Bibliography
+
+#magic.bibliography(title: none)
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+// Test breakable: false — content should not overflow to the next slide.
+// When breakable is false, each slide uses a non-breakable block so that
+// overflowing content is constrained rather than creating an additional page.
+
+#show: simple-theme.with(
+ config-common(breakable: false),
+)
+
+= Breakable False
+
+== Slide That Should Not Overflow
+
+#lorem(200)
+
+
+== Breakable False with Clip True
+
+#show: touying-set-config.with(config-common(clip: true))
+
+#lorem(200)
+
--- /dev/null
+#import "/lib.typ": *
+#import themes.default: *
+
+#show: default-theme.with(
+ config-colors(primary: red),
+ config-methods(alert: utils.alert-with-primary-color),
+)
+
+= Colors & Configuration Tests
+
+== Primary Color Configuration
+
+This theme uses red as the primary color.
+
+#alert[This is an alert with the primary color.]
+
+Regular text and #text(fill: red)[red colored text].
+
+== Different Color Scheme
+
+#show: touying-set-config.with(config-colors(
+ primary: blue,
+ secondary: green,
+))
+
+#alert[Alert with blue primary color.]
+
+Text with #text(fill: blue)[blue] and #text(fill: green)[green] colors.
+
+== Purple Theme
+
+#show: touying-set-config.with(config-colors(
+ primary: purple,
+ secondary: purple.lighten(30%),
+))
+
+#alert[Purple themed alert.]
+
+== Neutral Colors
+
+#show: touying-set-config.with(config-colors(
+ primary: gray,
+ secondary: gray.lighten(20%),
+))
+
+#alert[Neutral gray themed alert.]
+
+== Custom Color Methods
+
+#show: touying-set-config.with(config-methods(
+ alert: (self: none, it) => text(fill: orange, weight: "bold")[⚠ #it],
+))
+
+#alert[Custom orange alert with warning icon.]
+
+== Gradient Colors
+
+#show: touying-set-config.with(config-colors(
+ primary: gradient.linear(red, orange),
+))
+
+#alert[Gradient colored alert element.]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Complex Animations
+
+== Mark-Style Functions
+
+At subslide #touying-fn-wrapper((self: none) => str(self.subslide)), we can
+
+use #uncover("2-")[`#uncover` function] for reserving space,
+
+use #only("2-")[`#only` function] for not reserving space,
+
+#alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+
+== Callback-Style Functions
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ At subslide #self.subslide, we can
+
+ use #uncover("2-")[`#uncover` function] for reserving space,
+
+ use #only("2-")[`#only` function] for not reserving space,
+
+ #alternatives[call `#only` multiple times \u{2717}][use `#alternatives` function #sym.checkmark] for choosing one of the alternatives.
+])
+
+== only Function
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ #only("1", [First content])
+ #only("2", [Second content])
+ #only("3", [Third content])
+])
+
+== uncover Function
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ #uncover("1", [First content])
+ #uncover("2-", [Second content])
+])
+
+== alternatives Function
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ #alternatives[Ann][Bob][Christopher]
+ likes
+ #alternatives[chocolate][strawberry][vanilla]
+ ice cream.
+])
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ #alternatives(stretch: true, position: center)[Ann][Bob][Christopher]
+ likes
+ #alternatives(stretch: true, position: center)[chocolate][strawberry][vanilla]
+ ice cream.
+])
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ config-page(
+ height: 200pt,
+ width: 900pt,
+ margin: (x: 4pt, y: 4pt),
+ header: none,
+ footer: none,
+ ),
+)
+
+// == cover-spacing: list items
+//
+// On subslide 1 the left column has two list items hidden behind #pause.
+// The right column (reset via #meanwhile) always shows all three items.
+// The guide line sits at the vertical centre of the right column.
+// If cover-spacing is correct the left column is centred at the same height
+// and the guide line bisects both columns equally on every subslide.
+
+#slide(
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ place(top + left, dy: 90pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[#align(horizon)[
+ - text
+
+ #pause
+ - more
+
+ normal text
+ ]
+]
+
+#slide(
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ place(top + left, dy: 90pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ - text
+
+ - more text
+
+ normal text
+ ]
+]
+
+// == cover-spacing: enum items
+
+#slide(
+ composer: (1fr, 1fr),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ + first
+ #pause
+ + second
+ + third
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ + first
+ + second
+ + third
+ ]
+]
+
+// == cover-spacing: terms items
+
+#slide(
+ composer: (1fr, 1fr),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ / A: first
+ #pause
+ / B: second
+ / C: third
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ / A: first
+ / B: second
+ / C: third
+ ]
+]
+
+// == cover-spacing: paragraphs
+
+#slide(
+ composer: (1fr, 1fr),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ text
+
+ #pause
+
+ more
+
+ even more
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ text
+
+ more text
+
+ even more
+ ]
+]
+
+// == more complex case with multiple stuff interleaved
+
+#slide(setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ place(top + left, dy: 89pt)[#line(length: 100%, stroke: .4pt + red)]
+ place(top + left, dy: 135pt)[#line(length: 100%, stroke: .4pt + red)]
+ place(top + left, dy: 168pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+})[
+ #align(horizon)[
+ - first
+ #pause
+ - second
+ #pause
+ normal text
+ #pause
+ / term 1: one
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ - first
+ - second
+ normal text
+ / term 1: one
+ ]
+]
+
+
+// == more special cases
+
+#slide(
+ composer: (1fr, 1fr),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ paragraph text
+ #pause
+ - list item (hidden)
+ paragraph again
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ paragraph text
+ - list item (hidden)
+ paragraph again
+ ]
+]
+//== with multiple newlines
+#slide(
+ composer: (1fr, 1fr),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ place(top + left, dy: 103pt)[#line(length: 100%, stroke: .4pt + red)]
+ place(top + left, dy: 150pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ paragraph text2
+
+ #pause
+ - list item2 (hidden)
+
+ paragraph again
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ paragraph text2
+
+ - list item2 (hidden)
+
+ paragraph again
+ ]
+]
+
+//== with vertical space
+#slide(
+ composer: (1fr, 1fr),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ paragraph text3
+ #pause #v(1em)
+ - list item3 (hidden)
+ paragraph again
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ paragraph text3 #v(1em)
+ - list item3 (hidden)
+ paragraph again
+ ]
+]
+
+//== now some tests with nontight lists.
+//bc we do this locally this will break.
+
+#slide(
+ composer: (1fr, 1fr),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ //locally setting lists to be nontight.
+ show list.where(tight: true): magic.nontight
+
+ body
+ },
+)[
+ #align(horizon)[
+ - should break
+ #pause
+ - second
+ - third
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ - should break
+ - second
+ - third
+ ]
+]
+
+== Rect Cover
+
+#slide(
+ composer: (1fr, 1fr),
+ config: config-methods(cover: utils.cover-with-rect.with(
+ fill: rgb(255, 0, 0, 20%),
+ stroke: none,
+ )),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ - first
+ #pause
+ - second
+ - third
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ - first
+ - second #uncover("2-")[inline cover]
+ - third
+ ]
+]
+
+== Semi-transparent Cover
+
+#slide(
+ composer: (1fr, 1fr),
+ config: config-methods(cover: utils.semi-transparent-cover),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ - first
+ #pause
+ - second
+ - third
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ - first
+ - second #uncover("2-")[inline cover]
+ - third
+ ]
+]
+
+== Color Changing Cover
+
+#slide(
+ composer: (1fr, 1fr),
+ config: config-methods(cover: utils.color-changing-cover),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ - first
+ #pause
+ - second
+ - third
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ - first
+ - second
+ - third
+ ]
+]
+
+== Alpha Changing Cover
+
+#slide(
+ composer: (1fr, 1fr),
+ config: config-methods(cover: utils.alpha-changing-cover),
+ setting: body => {
+ place(top + left, dy: 56pt)[#line(length: 100%, stroke: .4pt + red)]
+ body
+ },
+)[
+ #align(horizon)[
+ - first
+ #pause
+ - second
+ - third
+ ]
+][
+ #meanwhile
+ #align(horizon)[
+ - first
+ - second
+ - third
+ ]
+]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+#set figure(numbering: none)
+
+= Cover & Overlay Tests
+
+== Semi-transparent Cover
+#show: touying-set-config.with(config-methods(
+ cover: utils.semi-transparent-cover,
+))
+// #show par: set text(2em)
+Regular content here.#pause This content appears |with semi-transparent cover effect. Math: $E = m c^(f f)_g$ and also Raw: `inline code` and Quote: #quote(block: false)[This is a quote.]
+
+#figure(
+ rect(fill: red),
+ caption: [A red rectangle.],
+)
+
+== Text Blocks with Semi-transparent Cover
+
+// #figure(
+// rect(fill: red, height: 1pt),
+// caption: [A red rectangle.],
+// )
+#skew[Normal text block]
+// Normal Text
+#pause
+#show skew: set text(2em)
+
+#{
+ show par: set text(2em)
+ align(center)[Big ff text]
+}
+#pause
+
+#skew(ax: 0deg)[Big ff text.]
+#pause
+
+#rotate(0deg)[Not Rotated]
+
+
+
+== Default Cover Behavior
+
+#show: touying-set-config.with(config-methods(
+ cover: utils.method-wrapper(hide),
+))
+
+Content that gets hidden completely when covered.#pause New content replaces the old content entirely.
+
+#figure(
+ rect(fill: red),
+ caption: [A red rectangle.],
+)
+
+== Color Changing Cover
+#show: touying-set-config.with(config-methods(
+ cover: utils.color-changing-cover.with(color: gray),
+))
+Regular content here.#pause This text should appear in gray when covered.
+
+#figure(
+ rect(fill: red),
+ caption: [A red rectangle.],
+)
+
+#pause
+
+More text with gray cover effect.
+
+== Color Changing Cover with Color Fallback Overlay
+#show: touying-set-config.with(config-methods(
+ cover: utils.color-changing-cover.with(
+ color: gray,
+ fallback-hide: utils.cover-with-rect,
+ fallback-hide-args: (fill: gray.transparentize(50%)),
+ ),
+))
+
+Regular content here.#pause This text should appear in gray when covered, and non-text content should be covered with a semi-transparent gray rectangle.
+
+#figure(
+ rect(fill: red),
+ caption: [A red rectangle.],
+)
+
+#pause
+
+More text with the same effect.
+
+== Alpha Changing Cover
+#show: touying-set-config.with(config-methods(
+ cover: utils.alpha-changing-cover.with(alpha: 25%),
+))
+Regular content here.#pause This text should appear semi-transparent when covered.
+
+#figure(
+ rect(fill: red),
+ caption: [A red rectangle.],
+)
+
+#pause
+
+More semi-transparent text.
+
+== Alpha Changing Cover with Semi-transparent Fallback Overlay
+#show: touying-set-config.with(config-methods(
+ cover: utils.alpha-changing-cover.with(
+ alpha: 25%,
+ fallback-hide: utils.semi-transparent-cover,
+ fallback-hide-args: (alpha: 75%),
+ ),
+))
+
+Regular content here.#pause This text should appear semi-transparent when covered, and non-text content should be covered with a semi-transparent gray overlay.
+
+#figure(
+ rect(fill: red),
+ caption: [A red rectangle.],
+)
+
+#pause
+
+More semi-transparent text.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#let outline-section-fn(config: (:), ..args) = touying-slide-wrapper(self => {
+ self.insert("header", "Contents")
+ touying-slide(self: self, ..args.named(), config: config, [
+ #text(2em, weight: "bold", [Contents rn])
+ #components.custom-progressive-outline(
+ self: self,
+ level: 1,
+ show-past: (true, false),
+ show-future: (true, false),
+ show-current: (true, true, false),
+ vspace: (.0em, .0em),
+ numbering: ("1.1",),
+ numbered: (true,),
+ title: none,
+ )])
+})
+
+#show: simple-theme.with(numbering: "1.1", config-common(
+ new-section-slide-fn: outline-section-fn,
+ receive-body-for-new-section-slide-fn: false,
+))
+
+#set heading(numbering: "1.1")
+
+= Start
+== Start Sub
+#lorem(5)
+= My content
+== My heading
+#lorem(5)
+---
+#{
+ // displays all top levels and all levels of the current top-level,
+ // with future siblings and other top levels semi-transparent
+ // and the current entry bold
+
+ show outline.entry: it => {
+ let relationship = utils.section-relationship(it)
+ let current = utils.current-heading()
+ let alpha = if relationship == -2 or relationship > 0 { 40% } else { 100% }
+ let weight = if relationship == 0 and current.level == it.level {
+ "bold"
+ } else { "regular" }
+ if it.level > 1 and calc.abs(relationship) > 1 {
+ text(fill: red, it)
+ } else {
+ text(fill: utils.update-alpha(text.fill, alpha), weight: weight, it)
+ }
+ }
+ outline(title: none)
+}
+---
+=== Subsubhedaing
+#lorem(3)
+
+== Another heading
+#lorem(5)
+
+= Next Top Level
+
+== Subsection
+#lorem(5)
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ config-common(
+ default-composer: cols.with(gutter: 3em),
+ ),
+)
+
+#set par(justify: true)
+
+== Custom gutter via default-composer
+
+#slide[
+ First column with 3em gutter. #lorem(20)
+][
+ Second column with 3em gutter. #lorem(20)
+]
+
+== Override default-composer per slide
+
+#slide(composer: cols.with(gutter: 0.5em))[
+ Overridden gutter (0.5em). #lorem(20)
+][
+ Second column. #lorem(20)
+]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Math Equation Animations
+
+== Simple Animation
+
+Touying equation with pause:
+
+$
+ f(x) & = pause x^2 + 2x + 1 \
+ & = pause (x + 1)^2 \
+$
+
+#meanwhile
+
+Touying equation is very simple.
+
+== Complex Animation
+
+#slide(repeat: 6, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ Solving the quadratic equation $x^2 + 4x + 3 = 0$:
+
+ $
+ x^2 + 4x + 3 & = 0 \
+ uncover("2-", x^2 + 4x) & = uncover("2-", -3) \
+ only("3-", x^2 + 4x + 4) & = only("3-", -3 + 4) \
+ uncover("4-", (x + 2)^2) & = uncover("4-", 1) \
+ only("5-", x + 2) & = only("5-", ±1) \
+ uncover("6-", x) & = uncover("6-", -2 ± 1) \
+ $
+
+ #meanwhile
+
+ #alternatives[
+ *Step 1:* Original equation
+ ][
+ *Step 2:* Move constant to right side
+ ][
+ *Step 3:* Add 4 to both sides to complete the square
+ ][
+ *Step 4:* Factor as perfect square
+ ][
+ *Step 5:* Take square root of both sides
+ ][
+ *Step 6:* Subtract 2 from both sides: $x = -1$ or $x = -3$
+ ]
+])
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Fit-To
+
+== height: no reflow
+#lorem(50)
+#utils.fit-to-height(reflow: false)[
+ #lorem(40)
+]
+
+== height: with reflow
+#lorem(50)
+#utils.fit-to-height(reflow: true)[
+ #lorem(40)
+]
+
+== height: with reflow but with force-height
+#lorem(50)
+#utils.fit-to-height(reflow: true, force-height: true)[
+ #lorem(40)
+]
+
+== width
+
+#utils.fit-to-width[
+ #lorem(2)
+]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Footnote with Animations
+
+== Labeled Footnote with pause
+
+You can edit Typst documents online.#footnote[https://typst.app/app] <fn>
+
+#pause
+
+Checkout Typst's website. @fn
+And the online app. #footnote(<fn>)
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ config-info(
+ author: "Beautiful Name",
+ ),
+)
+
+
+== Touying Get Config
+
+#touying-get-config().info.author
+
+#touying-get-config("info").author
+
+#touying-get-config("info.author")
+
+#touying-get-config("random.dict.value", default: "default value")
+
+== With custom Config
+
+//and once we randomly create a new config and try to retrieve it.
+#show: touying-set-config.with((random: (dict: (value: 123))))
+
+#touying-get-config("random.dict.value")
+
+//but this does not work:
+#touying-get-config("random.dict").value
+
+
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+// Test handout-only inline content (handout mode ON)
+#show: simple-theme.with(
+ config-common(handout: true),
+)
+
+== Regular Slide
+
+This content should always be visible.
+
+#handout-only[This content should only be visible in handout mode.]
+
+== Handout Only Slide <touying:handout>
+
+This entire slide is only visible in handout mode.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ config-common(handout: true),
+)
+
+== Slide with Handout Subslides
+
+// Slide 2: specify handout subslides via per-slide config
+#slide(config: config-common(handout-subslides: 2))[
+ #only(1)[Subslide 1 content.]
+
+ #only(2)[Subslide 2 content (this should appear in handout).]
+
+ #only(3)[Subslide 3 content (this should NOT appear in handout).]
+]
+
+== Multiple Handout Subslides
+
+// Slide 3: multiple handout subslides (renders subslides 1 and 3)
+#slide(config: config-common(handout-subslides: (1, 3)))[
+ #only(1)[Subslide 1 content (should appear in handout).]
+
+ #only(2)[Subslide 2 content (should NOT appear in handout).]
+
+ #only(3)[Subslide 3 content (should appear in handout).]
+]
+
+== Handout Subslides with String Notation
+
+// Slide 4: handout-subslides with string notation
+#slide(config: config-common(handout-subslides: "2-"))[
+ #only(1)[Subslide 1 content (should NOT appear in handout).]
+
+ #only(2)[Subslide 2 content (should appear in handout).]
+
+ #only(3)[Subslide 3 content (should appear in handout).]
+]
+
+
+== Negative Handout Subslide Indices
+// Slide 5: negative handout subslide indices (should render subslides 2 and 3)
+#slide(config: config-common(handout-subslides: (-2, -1)))[
+ #only(1)[Subslide 1 content (should NOT appear in handout).]
+
+ #only(2)[Subslide 2 content (should appear in handout).]
+
+ #only(3)[Subslide 3 content (should appear in handout).]
+]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ config-common(handout: true),
+)
+
+= Handout Mode Test
+
+== Slide with Animation
+
+This is the first part.
+
+#pause
+
+This is the second part that should appear in handout.
+
+#pause
+
+This is the final part.
+
+== Another Slide
+
+Content that should all be visible in handout mode.
+
+#pause
+
+More content.
--- /dev/null
+#import "/lib.typ": *
+#import themes.default: *
+#import "@preview/numbly:0.1.0": numbly
+
+#show: default-theme.with(
+ config-page(
+ header: text(gray, utils.display-current-short-heading(level: 2)),
+ footer: [Custom Footer Content],
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+= Headers & Footers Tests
+
+== Header Display Test
+
+This slide should show the section title "Headers & Footers Tests" in the header.
+
+Content of the slide goes here.
+
+== Different Header Styles
+
+#show: touying-set-config.with(config-page(
+ header: text(
+ blue,
+ weight: "bold",
+ utils.display-current-short-heading(level: 1),
+ ),
+))
+
+Now the header should be blue and bold, showing the main section.
+
+== Custom Footer Content
+
+#show: touying-set-config.with(config-page(
+ footer: [Page #context utils.slide-counter.display() | Custom Footer],
+))
+
+This slide has a custom footer with page number.
+
+== No Header or Footer
+
+#show: touying-set-config.with(config-page(
+ header: none,
+ footer: none,
+))
+
+This slide has no header or footer - clean layout.
+
+== Header with Progress
+
+#show: touying-set-config.with(config-page(
+ header: [
+ #text(gray, utils.display-current-short-heading(level: 2))
+ #h(1fr)
+ #text(size: 0.8em, [Slide #context utils.slide-counter.display()])
+ ],
+))
+
+Header now includes slide progress information.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Animations with `auto`, "h" (here)
+
+== auto
+
+Part A.
+#pause
+#only(auto)[Only at B.]
+#pause
+Part C.
+
+== here
+
+Part A.
+#pause
+#only("h")[Only here: at B.]
+#pause
+Part C.
+
+== here with range backwards
+
+Part A.
+#pause
+#only("-h")[Until here: at B.]
+#pause
+Part C.
+
+== here with range forwards
+
+//exactly like `pause` would behave
+Part A.
+#pause
+#only("h-")[From here: at B.]
+#pause
+Part C.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Animations with Range Inversion
+
+== Simple inversion
+
+Part A.
+#pause
+Part B.
+#pause
+Part C.
+
+#only("!3")[During A and B]
+
+== Range inversion
+
+Part A.
+#pause
+Part B.
+#pause
+Part C.
+
+#only("!2-3")[During A.]
+
+== here inversion
+Part A.
+#pause
+#only("!h")[Not B.]
+#pause
+Part C.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+== Item-by-item
+
+#slide[
+ #item-by-item[
+ - first
+ - second
+ - third
+ ]
+][
+ #meanwhile
+
+ #item-by-item(start: 2)[
+ - second
+ - third
+ ]
+][
+ #meanwhile
+
+ #item-by-item[
+ + first
+ + second
+ + third
+ ]
+][
+ #meanwhile
+
+ #item-by-item[
+ / Term 1: one
+ / Term 2: two
+ / Term 3: three
+ ]
+][
+ #meanwhile
+
+ #item-by-item[
+ - first
+ - second
+ - item a
+ - item b
+ - third
+ ]
+]
+
+== Item-by-item functions
+
+#slide[
+ #item-by-item-fn(none)[
+ - first
+ - second
+ - third
+ ]
+][
+ #meanwhile
+
+ #item-by-item-fn("current-bold")[
+ - second
+ - third
+ ]
+][
+ #meanwhile
+
+ #item-by-item-fn("current-highlight")[
+ + first
+ + second
+ + third
+ ]
+][
+ #meanwhile
+
+ #item-by-item-fn("past-faded")[
+ / Term 1: one
+ / Term 2: two
+ / Term 3: three
+ ]
+][
+ #meanwhile
+
+ #item-by-item-fn("past-progressive-faded")[
+ - first
+ - second
+ - item a
+ - item b
+ - third
+ ]
+][
+ #meanwhile
+
+ #item-by-item-fn(
+ item-by-item-functions
+ .at("past-progressive-faded")
+ .with(alpha: (exponential: 40%)),
+ )[
+ - first
+ - second
+ - third
+ ]
+]
+
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Layout & Composition
+
+== Side-by-side
+
+#slide[
+ First column.
+][
+ Second column.
+]
+
+== Three columns
+
+#slide(composer: (1fr, 2fr, 1fr))[
+ Left column.
+][
+ Middle column with more space.
+][
+ Right column.
+]
+
+== 2 Plus 1
+
+#slide(composer: (1fr, 1fr))[
+ Wider left column.
+][
+ Narrower right column.
+][#grid.cell(colspan: 2)[
+ #v(1fr)
+ #lorem(20)
+]]
+
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ config-common(breakable: false),
+ footer-right: none,
+)
+
+= Lazy Vspace
+
+== Basic: lazy-layout with lazy-v
+
+// The block should shrink to fit the content, not expand to the full page height.
+// The 1fr lazy-v pushes content to the bottom within the measured block height.
+#lazy-layout(
+ block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(10)
+ #lazy-v(1fr)
+ Bottom of block.
+ ],
+)
+
+== Basic: two blocks with lazy-v side by side (manual)
+
+#lazy-layout(grid(
+ columns: (1fr, 1fr),
+ gutter: 1em,
+ block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(10)
+ #lazy-v(1fr)
+ Bottom left.
+ ],
+
+ block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(20)
+ #lazy-v(1fr)
+ Bottom right.
+ ],
+))
+
+== `cols` with lazy-layout: true
+
+// Both blocks should be the same height (matching the taller one),
+// and the overall layout should NOT fill the entire page height.
+#cols(lazy-layout: true)[
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(10)
+ #lazy-v(1fr)
+ Bottom left.
+ ]
+][
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(20)
+ #lazy-v(1fr)
+ Bottom right.
+ ]
+]
+
+
+== `cols` without lazy-layout (explicit false)
+
+// Opt out of lazy-layout by passing lazy-layout: false.
+// lazy-v markers are invisible and blocks are not height-equalized.
+#cols(lazy-layout: false)[
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(10)
+
+ Bottom left.
+ ]
+][
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(20)
+
+ Bottom right.
+ ]
+]
+
+== Mixed: cols with multi-blocks
+
+// Left column: a single block with more text to be taller.
+// Right column: two blocks, each with a lazy-v marker.
+// Only the last lazy-v per x-column is activated, so the bottom block on the
+// right expands to fill the remaining height, while the top block stays compact.
+#cols[
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(30)
+ #lazy-v(1fr)
+ Bottom left.
+ ]
+][
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(5)
+ #lazy-v(1fr)
+ End of top block.
+ ]
+ #block(fill: luma(220), inset: .5em, radius: .2em, width: 100%)[
+ #lorem(5)
+ #lazy-v(1fr)
+ Bottom right.
+ ]
+]
+
+= Lazy Hspace
+
+== Basic: lazy-layout with lazy-h (single block)
+
+// The block should shrink to fit the content width, not expand to the full page width.
+// The 1fr lazy-h pushes "Right." to the right edge within the measured block width.
+#lazy-layout(
+ direction: ltr,
+ block(fill: luma(220), inset: .5em, radius: .2em, height: 2em)[
+ Left. #lazy-h(1fr) Right.
+ ],
+)
+
+== Basic: two blocks with lazy-h stacked (manual)
+
+// Both blocks should be the same width (matching the wider one),
+// and the overall layout should NOT fill the entire page width.
+#lazy-layout(
+ direction: ltr,
+ stack(
+ dir: ttb,
+ spacing: 1em,
+ block(fill: luma(220), inset: .5em, radius: .2em, height: 2em)[
+ #lorem(3) #lazy-h(1fr) Right label.
+ ],
+ block(fill: luma(220), inset: .5em, radius: .2em, height: 2em)[
+ #lorem(6) #lazy-h(1fr) Right label.
+ ],
+ ),
+)
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Math Equation Numbering Tests
+
+== Basic Equation Numbering
+
+#set math.equation(numbering: "(1)")
+
+Here's a numbered equation:
+
+$ x^2 + y^2 = z^2 $ <pythagorean>
+
+#pause
+
+And another one:
+
+$ E = m c^2 $ <einstein>
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+#import "@preview/numbly:0.1.0": numbly
+
+#show: simple-theme
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+== Outline <touying:hidden>
+
+#components.adaptive-columns(outline(title: none, indent: 1em))
+
+= First Section
+
+== First Subsection
+
+Content of first subsection.
+
+== Second Subsection
+
+Content of second subsection.
+
+= Second Section
+
+== Third Subsection
+
+Content of third subsection.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Pagination
+
+== Multiple Pages
+
+#lorem(200)
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show raw.where(block: true): body => block(
+ width: 100%,
+ fill: luma(240),
+ outset: (x: 0pt, y: 8pt),
+ inset: (x: 16pt, y: 8pt),
+ radius: 8pt,
+ {
+ set par(justify: false)
+ body
+ },
+)
+#show: simple-theme
+
+== Normal Mode with pause and meanwhile
+
+#touying-raw(```rust
+fn main() {
+ // pause
+ println!("Hello, world!");
+ // meanwhile
+}
+```)
+
+== fill-empty-lines disabled
+
+#touying-raw(fill-empty-lines: false, ```js
+function foo() {
+ // pause
+ return 42;
+}
+```)
+
+== Simple mode
+
+#touying-raw(simple: true, ```rust
+fn main() { #pause;println!("Hello!");#meanwhile; }
+```)
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ config-common(new-section-slide-fn: none),
+)
+
+== Animated Slide <animated>
+
+Step 1 #pause Step 2
+
+== Recall all (default):
+
+Recall the entire slide:
+
+#touying-recall(<animated>)
+
+== Recall subslide 2:
+
+Recall only the second subslide:
+
+#touying-recall(<animated>, subslide: 2)
+
+== Recall subslide negative:
+
+Recall the last subslide (via negative index):
+
+#touying-recall(<animated>, subslide: -1)
+
+== Slide with waypoints <wp-slide>
+
+#waypoint(<phase-a>, advance: false)
+Phase A content
+#waypoint(<phase-b>)
+Phase B content
+#pause
+More B content
+
+== Recall auto (last subslide):
+Recall only the last subslide.
+
+#touying-recall(<animated>, subslide: auto)
+
+#touying-recall(<wp-slide>, subslide: auto)
+
+== Recall waypoints (last of each):
+Recall the last subslide of each waypoint.
+
+#touying-recall(<wp-slide>, subslide: "waypoints")
+
+== Recall waypoint range:
+
+Show only the subslides covered by `<phase-b>`:
+
+#touying-recall(<wp-slide>, subslide: <phase-b>)
+
+== Recall get-last:
+
+Show only the last subslide of `<phase-b>`:
+
+#touying-recall(<wp-slide>, subslide: get-last(<phase-b>))
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Recall & Reference Tests
+
+== First Topic <first-topic>
+
+This is the first topic that we'll reference later.
+
+Key points:
+- Important concept A
+- Important concept B
+- Important concept C
+
+== Second Topic <second-topic>
+
+This slide introduces another concept.
+
+Content about the second topic goes here.
+
+== Third Topic
+
+Now we can recall previous topics using the recall function.
+
+#touying-recall(<first-topic>)
+
+== Another Reference Example
+
+We can also recall the second topic:
+
+#touying-recall(<second-topic>)
+
+== Multiple Recalls
+
+Sometimes we need to reference multiple previous slides:
+
+First, let's recall the first topic:
+#touying-recall(<first-topic>)
+
+Then, let's recall the second topic:
+#touying-recall(<second-topic>)
+
+== Cross-references in Content
+
+You can also reference slides inline like in your text content.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(config-common(show-only-notes: true))
+
+= Show Notes Tests
+
+== Full-Screen Speaker Notes
+
+This slide has speaker notes that appear as the main content with the slide as a thumbnail.
+
+#speaker-note[
+ + This is the first speaker note point
+ + Remember to explain the concept clearly
+ + Don't forget to mention the example
+]
+
+Regular slide content is shown as a thumbnail in the top right.
+
+== No Notes Slide
+
+This slide has no speaker notes.
+
+== Animated Slide With Notes
+
+Content before animation.
+
+#pause
+
+#speaker-note[
+ Explain what happens after the pause.
+]
+
+Content after animation pause.
+
+== Speaker Notes with Pause Inside Note
+
+Slide content here.
+
+#speaker-note[
+ + This is the first speaker note point #pause
+ + Remember to explain the concept clearly
+ + Don't forget to mention the example
+]
--- /dev/null
+#import "/lib.typ": *
+
+#import themes.simple: *
+#show: simple-theme.with(
+ config-common(
+ export-mode: "presentation",
+ handout-mode: false,
+ show-hide-set-list-marker-none: true,
+ ),
+ config-info(
+ title: [Appendix Test],
+ ),
+)
+
+#set heading(numbering: "1.1")
+#show heading.where(level: 1): set heading(numbering: "1.")
+
+== First Slide
+
+First
+#show: touying-set-config.with(config-methods(
+ cover: utils.method-wrapper(hide),
+))
+
+#pause
+#show par: set text(2em)
+
+More bigger content
+
+
+== Second Slide
+
+Second
+#pause
+#show: touying-set-config.with(config-methods(
+ cover: utils.semi-transparent-cover,
+))
+
+Semi Transparent Cover Effect
+
+
+== Third Slide
+
+Third
+
+#show: appendix
+
+#set heading(numbering: "A.1")
+#counter(heading).update((1, 0))
+// The cover change must take effect on the appendix slides.
+#show: touying-set-config.with(config-methods(
+ cover: utils.method-wrapper(hide),
+))
+= Appendix <touying:hidden> //does not render the section slide, but still registers the section for numbering
+== First Appendix
+// Cover for #pause should be "hide" (content disappears completely, no semi-transparent)
+My Appendix Content #pause More Appendix Content
+
+== Second Appendix
+// Case 2 (Bug 2 fix): config right after heading (slide-parts == ()), leading-preamble
+// was already set by the counter update above. The counter update must not be dropped.
+// Heading numbering should be A.2 (counter was set to (1,0), incremented once for A.1)
+// and the cover change here must also take effect.
+#show: touying-set-config.with(config-methods(
+ cover: utils.semi-transparent-cover,
+))
+Second Appendix Content #pause Semi-Transparent Cover Here
+
+== Third Appendix
+// Case 3: nested configs in absorbing context (before first heading).
+// Both configs must be applied and counter update must survive.
+// Heading should be A.3, content visible, pause uses hide cover.
+#counter(heading.where(level: 2)).update(2)
+#show: touying-set-config.with(config-methods(
+ cover: utils.method-wrapper(hide),
+))
+#show: touying-set-config.with(config-info(
+ author: [Test Author],
+))
+Third Appendix Content #pause Hidden Pause Content
+
+== Fourth Appendix
+// Case 4: immediate path — config appears AFTER slide content (slide-parts non-empty).
+// This exercises the non-deferred immediate path unchanged by our bug fixes.
+Before config change.
+#show: touying-set-config.with(config-methods(
+ cover: utils.semi-transparent-cover,
+))
+#pause
+After config change with semi-transparent cover.
+
+#show: touying-set-config.with((:), defer: true) // test that preamble capture properly stops.
+
+== Fifth Appendix
+// Case 5 (absorb-leading-preamble reset): a pagebreak/--- after a section slide must
+// NOT absorb subsequent content into leading-preamble. The content below must appear
+// on its own slide, not be silently dropped.
+---
+Content after pagebreak must be visible on this slide.
+#pause
+And this too.
+
+== Sixth Appendix
+// Case 6: same as Case 5 but using an explicit #pagebreak() instead of ---.
+// Content after the pagebreak must appear, not be swallowed by absorb-leading-preamble.
+#show: touying-set-config.with((:), defer: true) // test that preamble capture properly stops.
+#pagebreak()
+Content after explicit pagebreak must be visible.
+#pause
+And this too.
+
+== Seventh Appendix
+// Case 8 (Fix 1): deferred config appears immediately after a heading with NO body
+// content (slide-parts == ()). == Seventh Appendix must flush as its own empty slide.
+// The config body begins fresh on the next slide — NOT as part of == Seventh Appendix.
+#show: touying-set-config.with(
+ config-methods(
+ cover: utils.method-wrapper(hide),
+ ),
+ defer: true,
+)
+== Eighth Appendix
+Eighth appendix content — heading above must be on a SEPARATE preceding slide.
+#pause
+Hidden pause (hide cover must apply here).
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme
+
+= Simple Animations
+
+== pause
+
+First #pause Second
+
+#pause
+
+Third
+
+== meanwhile
+
+First
+
+#pause
+
+Second
+
+#meanwhile
+
+Third
+
+#pause
+
+Fourth
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+#import "@preview/numbly:0.1.0": numbly
+
+#show: simple-theme.with(
+ config-common(slide-level: 3),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+= Slide Levels Configuration Tests
+
+== Level 2 Heading (Not a slide when slide-level is 3)
+
+This is content under a level 2 heading. When slide-level is set to 3, this won't create a new slide.
+
+=== Level 3 Heading (This creates a slide)
+
+This level 3 heading creates a new slide because slide-level is set to 3.
+
+=== Another Level 3 Slide
+
+Each level 3 heading creates a separate slide.
+
+Content for this slide.
+
+== Another Level 2 Section
+
+=== First Slide in Section
+
+Content for the first slide in this section.
+
+=== Second Slide in Section
+
+Content for the second slide in this section.
+
+#show: touying-set-config.with(config-common(slide-level: 2))
+
+== Now Level 2 Creates Slides
+
+After changing slide-level to 2, this level 2 heading creates a new slide.
+
+=== Level 3 is now subsection content
+
+This level 3 heading is now treated as subsection content, not a new slide.
+
+=== More subsection content
+
+More content within the same slide.
+
+== Another Level 2 Slide
+
+This creates another slide since slide-level is now 2.
+
+#show: touying-set-config.with(config-common(slide-level: 1))
+
+= Level 1 Creates Slides Now
+
+---
+
+With slide-level set to 1, only level 1 headings create new slides.
+
+== This is subsection content
+
+Level 2 and 3 headings are now subsection content.
+
+=== Even deeper subsection
+
+All within the same slide.
--- /dev/null
+#import "/lib.typ": *
+#import themes.metropolis: *
+
+#set heading(numbering: "1.")
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ config-common(
+ show-notes-on-second-screen: right,
+ receive-body-for-new-section-slide-fn: false,
+ ),
+)
+
+#outline-slide()
+
+= First Section
+
+#speaker-note[Notes for the first section slide]
+
+== Slide One
+
+Content on slide one.
+
+= Second Section
+
+#speaker-note[Notes for the second section]
+#speaker-note[Additional notes for second section]
+
+== Slide Two
+
+Content on slide two.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(config-common(show-notes-on-second-screen: right))
+
+= Speaker Note After Slide
+
+== Basic: slide followed by speaker-note
+
+#slide[Slide content here]
+
+#speaker-note[
+ This note should attach to the previous slide, not create a new one.
+]
+
+== Pause: slide with pause followed by speaker-note
+
+#slide[
+ Before pause
+
+ #pause
+
+ After pause
+]
+
+#speaker-note[
+ Notes for the slide with pause.
+]
+
+== Multiple: slide followed by multiple speaker-notes
+
+#slide[Multiple notes test]
+
+#speaker-note[First note]
+#speaker-note[Second note]
+
+== Normal: inline speaker-note still works
+
+Normal content with inline note.
+
+#speaker-note[This is an inline note on a normal slide.]
+
+More content here.
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(config-common(show-notes-on-second-screen: right))
+
+= Speaker Notes Tests
+
+== Basic Speaker Notes
+
+This slide contains speaker notes that won't be visible in normal presentation mode.
+
+#speaker-note[
+ + This is the first speaker note point #pause
+ + Remember to explain the concept clearly
+ + Don't forget to mention the example
+]
+
+Regular slide content continues here.
+
+== Multiple Speaker Notes
+
+First paragraph of content.
+
+#speaker-note[
+ First set of speaker notes for this section.
+]
+
+Second paragraph with more information.
+
+#speaker-note[
+ + Additional notes for the second part
+ + Key points to emphasize
+ + Transition to next slide
+]
+
+== Speaker Notes with Animation
+
+Content before animation.
+
+#pause
+
+#speaker-note[
+ Explain what happens after the pause.
+]
+
+Content after animation pause.
+
+#meanwhile
+
+Meanwhile content with its own speaker notes.
+
+#speaker-note[
+ Notes specific to the meanwhile content.
+]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+#import "@preview/cetz:0.5.0"
+#import "@preview/fletcher:0.5.8" as fletcher: edge, node
+
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+
+#show: simple-theme.with(footer-right: []) //don't display slide number anymore
+
+// -----------------------------------------------
+// Test 1: Explicit waypoint with uncover
+// -----------------------------------------------
+
+== Explicit Waypoints
+
+Always visible.
+
+#waypoint(<reveal>)
+First phase.
+#pause
+Second phase.
+
+#uncover(
+ <reveal>,
+)[Revealed during waypoint. Basically a meanwhile starting from the waypoint.]
+
+// -----------------------------------------------
+// Test 2: Waypoint without advance
+// -----------------------------------------------
+
+== No Advance Waypoint
+
+#waypoint(<here>, advance: false)
+
+Everything on subslide 1.
+
+#uncover(<here>)[This should be visible on subslide 1.]
+
+// -----------------------------------------------
+// Test 3: Explicit waypoint with effect
+// -----------------------------------------------
+
+== Effect Waypoint
+
+Normal text.
+
+#waypoint(<highlight>)
+
+#effect(text.with(fill: red), <highlight>)[Red from waypoint onward.]
+
+// -----------------------------------------------
+// Test 4: Multiple explicit waypoints and only
+// -----------------------------------------------
+
+== Multiple Waypoints
+
+#waypoint(<first>, advance: false)
+
+First phase.
+
+#waypoint(<second>)
+
+Second phase.
+
+#only(<first>)[Only during first.]
+
+#only(<second>)[Only during second.]
+
+// -----------------------------------------------
+// Test 5: get-first / get-last
+// -----------------------------------------------
+
+== Get First and Last
+#waypoint(<a>, advance: false)
+
+Phase A.1.
+
+#pause
+
+Phase A.2.
+
+#waypoint(<b>)
+
+Phase B.1.
+#pause
+Phase B.2.
+
+#only(get-first(<a>))[Exactly on first subslide of A: Phase A.1.]
+
+#only(get-last(<b>))[Exactly on last subslide of B: Phase B.2.]
+
+// -----------------------------------------------
+// Test 6: from-wp() — visible from waypoint onward
+// -----------------------------------------------
+
+== From
+
+Intro.
+
+#waypoint(<step>)
+
+Step content.
+
+#waypoint(<next>)
+
+Next content.
+
+#uncover(from-wp(<step>))[Visible from step onward (including next).]
+
+// -----------------------------------------------
+// Test 7: until-wp() — visible before a waypoint
+// -----------------------------------------------
+
+== Until
+
+#waypoint(<phase-1>, advance: false)
+Phase 1 content.
+
+#waypoint(<phase-2>)
+Phase 2 content.
+
+#uncover(until-wp(<phase-2>))[Only visible before phase 2.]
+
+#uncover(from-wp(<phase-2>))[Only visible from phase 2.]
+
+// -----------------------------------------------
+// Test 8: Bounded range with (from-wp, until-wp) array
+// -----------------------------------------------
+
+== Bounded Range
+
+#waypoint(<rng-a>, advance: false)
+Range A.
+
+#waypoint(<rng-b>)
+Range B.
+
+#waypoint(<rng-c>)
+Range C.
+
+#uncover((from-wp(<rng-a>), until-wp(<rng-c>)))[Visible during A and B only.]
+
+#only((from-wp(<rng-b>), until-wp(<rng-c>)))[Only during B.]
+
+// -----------------------------------------------
+// Test 9: prev-wp / next-wp
+// -----------------------------------------------
+
+== Prev and Next WP
+
+#waypoint(<nav-a>, advance: false)
+Section A.
+
+#waypoint(<nav-b>)
+Section B.
+
+#waypoint(<nav-c>)
+Section C.
+
+#only(next-wp(<nav-a>))[This shows during B (next after A).]
+
+#only(prev-wp(<nav-c>))[This shows during B (prev before C).]
+
+// ------------------------------------------------
+// Test 9.5: not-wp
+// ------------------------------------------------
+
+== Not WP
+#waypoint(<not-a>, advance: false)
+#uncover(<not-a>)[Visible during A.]
+#pause
+A-2.
+#waypoint(<not-b>)
+B.
+
+#only(not-wp(get-first(<not-a>)))[Nearing B.]
+
+
+// -----------------------------------------------
+// Test 10: from-wp(next-wp()) and until-wp(prev-wp()) composition
+// -----------------------------------------------
+
+== Composed Shifts
+
+#waypoint(<cs-a>, advance: false)
+Part A.
+
+#waypoint(<cs-b>)
+Part B.
+
+#waypoint(<cs-c>)
+Part C.
+
+#uncover(from-wp(next-wp(<cs-a>)))[From B onward (next after A).]
+
+#uncover(until-wp(prev-wp(
+ <cs-c>,
+)))[Until before C (prev of C = B => until B).]
+
+// -----------------------------------------------
+// Test 11: next-wp(until-wp()) pushed inward
+// -----------------------------------------------
+
+== Next-WP Until Push
+
+#waypoint(<pu-a>)
+Alpha.
+
+#waypoint(<pu-b>)
+Beta.
+
+#waypoint(<pu-c>)
+Gamma.
+
+// next-wp(until-wp(<pu-b>)) becomes until-wp(next-wp(<pu-b>)) = until-wp(<pu-c>)
+// So visible: subslides before pu-c = during A and B.
+#uncover(next-wp(until-wp(<pu-a>), amount: 2))[Until Next^2(A): before C.]
+
+
+
+// -----------------------------------------------
+// Test 12: Alternatives with `at:`
+// -----------------------------------------------
+
+== Alternatives at Waypoints
+
+#waypoint(<alt-a>, advance: false)
+
+Phase A.
+
+#waypoint(<alt-b>)
+
+Phase B.
+
+#alternatives(at: (<alt-a>, <alt-b>))[Alt content A.][Alt content B.]
+
+// -----------------------------------------------
+// Test 13: Implicit waypoints with uncover
+// -----------------------------------------------
+
+== Implicit Uncover
+
+Always visible.
+
+#uncover(<imp-reveal>)[Implicitly waypointed content.]
+
+// -----------------------------------------------
+// Test 14: Implicit waypoints with effect
+// -----------------------------------------------
+
+== Implicit Effect
+
+Normal text.
+
+#effect(text.with(fill: red), <imp-red>)[Red via implicit waypoint.]
+
+// -----------------------------------------------
+// Test 15: Implicit waypoints with only
+// -----------------------------------------------
+
+== Implicit Only
+
+Content always shown.
+
+#only(<imp-show>)[Only via implicit waypoint.]
+
+#uncover(<imp-end>)[New waypoint.]
+
+// -----------------------------------------------
+// Test 16: Multiple implicit waypoints
+// -----------------------------------------------
+
+== Multiple Implicit
+
+Base content.
+
+#uncover(<imp-a>)[Phase A content.]
+
+#pause
+Last of A, always.
+
+
+#effect(text.with(fill: blue), <imp-b>)[Phase B styled.]
+
+#only(<imp-c>)[Phase C only.]
+
+#only((get-last(<imp-a>), <imp-c>))[On last of A, and at C.]
+
+// -----------------------------------------------
+// Test 17: Mixed explicit and implicit with from-wp()
+// -----------------------------------------------
+
+== Mixed Waypoints
+
+#waypoint(<explicit-wp>, advance: false)
+
+Explicit phase.
+
+#uncover(<implicit-wp>)[Implicit phase content.]
+
+#uncover(from-wp(<explicit-wp>))[Visible from explicit onward.]
+
+// -----------------------------------------------
+// Test 18: Same implicit label used twice (idempotent)
+// -----------------------------------------------
+
+== Duplicate Implicit
+
+#uncover(<dup>)[First use.]
+
+#uncover(<dup>)[Second use — same label, no extra pause.]
+
+// -----------------------------------------------
+// Test 19: Waypoints with touying-equation
+// -----------------------------------------------
+
+== Equation with Waypoints
+
+Intro text.
+
+#waypoint(<eq-phase>)
+
+$
+ f(x) & = pause x^2 + 2x + 1 \
+ & = pause (x + 1)^2 \
+$
+
+#uncover(<eq-phase>)[Equation explanation visible from waypoint.]
+
+#waypoint(<eq-after>)
+
+Some more explanation.
+
+#only(<eq-after>)[Visible after equation explanation.]
+
+
+// -----------------------------------------------
+// Test 20: Callback-style with uncover + only + effect
+// -----------------------------------------------
+
+== Callback with Waypoints
+
+#slide(self => {
+ let (uncover, only, effect) = utils.methods(self)
+ [
+ Base content in callback.
+
+ #waypoint(<cb-a>)
+
+ #uncover(<cb-a>)[Uncovered from cb-a.]
+
+ #waypoint(<cb-b>)
+
+ #only(<cb-b>)[Only during cb-b.]
+
+ #effect(text.with(fill: red), <cb-a>)[Red during cb-a.]
+ ]
+})
+
+// -----------------------------------------------
+// Test 21: Callback-style with get-first / get-last / from-wp
+// -----------------------------------------------
+
+== Callback Get-First Get-Last
+
+#slide(self => {
+ let (only,) = utils.methods(self)
+ [
+ #waypoint(<m1>, advance: false)
+ Phase 1. \
+ #pause
+ Phase 1 continued. \
+ #waypoint(<m2>)
+ Phase 2. \
+
+ #only(get-first(<m1>))[Exactly first of m1.]
+ #only(get-last(<m1>))[Exactly last of m1.]
+ #only(from-wp(<m2>))[From m2 onward.]
+ ]
+})
+
+// -----------------------------------------------
+// Test 22: Callback-style with alternatives at:
+// -----------------------------------------------
+
+== Callback Alternatives
+
+#slide(self => {
+ [
+ #waypoint(<ca>, advance: false)
+ Phase A. \
+ #waypoint(<cb>)
+ Phase B. \
+ #alternatives(at: (<ca>, <cb>))[Alt A callback.][Alt B callback.]
+ ]
+})
+
+// -----------------------------------------------
+// Test 23: item-by-item after explicit waypoint
+// -----------------------------------------------
+
+== Item-by-item with Waypoint
+
+Intro content.
+
+#waypoint(<list-start>)
+
+#item-by-item[
+ - First item
+ - Second item
+ - Third item
+]
+
+#uncover(<list-start>)[List is being revealed above.]
+
+// -----------------------------------------------
+// Test 24: Callback item-by-item with waypoint start
+// -----------------------------------------------
+
+== Callback Item-by-item
+
+#slide(self => {
+ let (uncover, only) = utils.methods(self)
+ [
+ Intro.
+
+ #waypoint(<ibi-wp>)
+
+ #item-by-item[
+ - Alpha
+ - Beta
+ - Gamma
+ ]
+
+ #only(get-last(<ibi-wp>))[All items revealed.]
+ ]
+})
+
+// -----------------------------------------------
+// Test 25: Forward reference — use waypoint before it is defined
+// -----------------------------------------------
+
+== Forward Reference
+
+// Title shown until the summary waypoint (forward reference)
+#uncover(until-wp(<summary>))[Title: shown before summary.]
+
+Content.
+
+#waypoint(<summary>)
+
+Summary text.
+
+#uncover(from-wp(<summary>))[Summary visible.]
+
+// -----------------------------------------------
+// Test 26: Inclusive bounded range with next-wp
+// -----------------------------------------------
+
+== Inclusive Range
+
+#waypoint(<ir-a>, advance: false)
+Part A.
+
+#waypoint(<ir-b>)
+Part B.
+
+#waypoint(<ir-c>)
+Part C.
+
+// from-wp(<ir-a>), until-wp(<ir-b>) = only A
+// from-wp(<ir-a>), next-wp(until-wp(<ir-b>)) = until-wp(next-wp(<ir-b>)) = until-wp(<ir-c>) → A and B
+#only((from-wp(<ir-a>), until-wp(<ir-b>)))[Exactly during A.]
+
+#only((
+ from-wp(<ir-a>),
+ next-wp(until-wp(<ir-b>)),
+))[During A and B (inclusive of B).]
+
+// -----------------------------------------------
+// Test 27: prev-wp / next-wp with amount parameter
+// -----------------------------------------------
+
+== Amount Shift
+
+#waypoint(<am-a>, advance: false)
+First.
+
+#waypoint(<am-b>)
+Second.
+
+#waypoint(<am-c>)
+Third.
+
+#waypoint(<am-d>)
+Fourth.
+
+// next-wp(<am-a>, amount: 2) = skip 2 forward = am-c
+#only(next-wp(<am-a>, amount: 2))[During C (jumped forward 2 from A).]
+
+// prev-wp(<am-d>, amount: 2) = skip 2 backward = am-b
+#only(prev-wp(<am-d>, amount: 2))[During B (jumped backward 2 from D).]
+
+// Compose with from: from-wp(next-wp(<am-a>, amount: 2)) = from am-c onward
+#uncover(from-wp(next-wp(
+ <am-a>,
+ amount: 2,
+)))[From Next^2(A): C onward (amount: 2).]
+
+// -----------------------------------------------
+// Test 28: Hierarchy — parent first, then child (two subslides)
+// -----------------------------------------------
+
+== Parent Then Child
+
+#waypoint(<top>, advance: false)
+Top content.
+
+#waypoint(<top:sub>)
+Sub content.
+
+#only(get-first(<top>))[Exactly first of top (the parent phase).]
+
+#only(get-last(<top>))[Exactly last of top: the sub phase.]
+
+#only(<top:sub>)[Only during the sub-waypoint.]
+
+// -----------------------------------------------
+// Test 29: Hierarchy — child first, parent is no-op
+// -----------------------------------------------
+
+== Child First
+
+#waypoint(<rev:child>, advance: false)
+Child content.
+
+#waypoint(<rev>)
+This is a no-op; both on same subslide.
+
+#only(<rev:child>)[During the child waypoint.]
+
+#only(<rev>)[During parent — same as child.]
+
+// -----------------------------------------------
+// Test 30: Implicit child first, then implicit parent — no-op
+// -----------------------------------------------
+
+== Implicit Child First
+
+#uncover(<ic:sub>)[Child implicit content.]
+
+#uncover(<ic>)[Parent implicit — no-op, same subslide as child.]
+
+// -----------------------------------------------
+// Test 31: Implicit parent first, then implicit child (two subslides)
+// -----------------------------------------------
+
+== Implicit Parent Then Child
+
+#uncover(<ip>)[Parent implicit content.]
+
+#uncover(<ip:sub>)[Child implicit content.]
+
+#only(get-first(<ip>))[First of parent.]
+
+#only(get-last(<ip>))[Last of parent: the child phase.]
+
+// -----------------------------------------------
+// Test 32: Deep hierarchy — three levels
+// -----------------------------------------------
+
+== Deep Hierarchy
+
+#waypoint(<d>, advance: false)
+Level 0.
+
+#waypoint(<d:a>)
+Level 1.
+
+#waypoint(<d:a:x>)
+Level 2.
+
+#only(get-first(<d>))[First of d: level 0.]
+
+#only(get-last(<d>))[Last of d: level 2.]
+
+#only(get-first(<d:a>))[First of d:a: level 1.]
+
+#only(get-last(<d:a>))[Last of d:a: level 2.]
+
+// -----------------------------------------------
+// Test 33: Mixed explicit and implicit hierarchy
+// -----------------------------------------------
+
+== Mixed Hierarchy
+
+#waypoint(<mx>, advance: false)
+Explicit parent.
+
+#uncover(<mx:detail>)[Implicit child detail.]
+
+#only(get-last(<mx>))[Last of mx: the detail phase.]
+
+// -----------------------------------------------
+// Test 34: Hierarchy with from/until
+// -----------------------------------------------
+
+== Hierarchy With From
+
+#waypoint(<hf>, advance: false)
+Phase A.
+
+#waypoint(<hf:more>)
+Phase B.
+
+#waypoint(<other>)
+Phase C.
+
+#uncover(from-wp(<hf>))[From hf onward — visible on A, B, and C.]
+
+#only((from-wp(<hf>), until-wp(<other>)))[During A and B only.]
+
+// -----------------------------------------------
+// Test 35: prev-wp / next-wp with hierarchical labels
+// -----------------------------------------------
+
+== Hierarchy Nav
+
+#waypoint(<hn-before>, advance: false)
+Before the group.
+
+#waypoint(<hn:a>)
+Part A.
+
+#waypoint(<hn:b>)
+Part B.
+
+#waypoint(<hn:c>)
+Part C.
+
+#waypoint(<hn-after>)
+After the group.
+
+// Exact child navigation (as before)
+#only(next-wp(<hn:a>))[During B (next after child A).]
+#only(prev-wp(<hn:c>))[During B (prev before child C).]
+
+// Parent navigation (no explicit <hn> waypoint — virtual parent):
+// next-wp(<hn>) anchors to last child (hn:c), steps +1 = hn-after
+#only(next-wp(<hn>))[During hn-after (next past entire group).]
+// prev-wp(<hn>) anchors to first child (hn:a), steps -1 = hn-before
+#only(prev-wp(<hn>))[During hn-before (prev before entire group).]
+
+// -----------------------------------------------
+// Test 36: Forward reference in callback
+// -----------------------------------------------
+
+== Callback Forward Reference
+
+#slide(self => {
+ let (uncover, only) = utils.methods(self)
+ [
+ #uncover(until-wp(<cb-summary>))[Title: shown before summary.]
+
+ Content.
+
+ #waypoint(<cb-summary>)
+
+ Summary text.
+
+ #uncover(from-wp(<cb-summary>))[Summary visible.]
+
+ #only(<cb-summary>)[Only during summary.]
+ ]
+})
+
+
+// -----------------------------------------------
+// Test 37: touying-equation then waypoint
+// -----------------------------------------------
+
+== Equation Block then Waypoint
+
+Before equation.
+
+#touying-equation(`f(x) = pause x^2`)
+
+#waypoint(<after-eq-block>)
+
+After equation text via waypoint.
+
+#uncover(<after-eq-block>)[Only after equation animation.]
+
+
+// -----------------------------------------------
+// Test 38: touying-raw then waypoint
+// -----------------------------------------------
+
+== Raw Block then Waypoint
+
+Before raw.
+
+#touying-raw(```rust
+fn main() {
+ // pause
+ println!("Hello!");
+}
+```)
+
+#waypoint(<after-raw>)
+
+After raw text via waypoint.
+
+#only(<after-raw>)[Only after raw animation.]
+
+
+// -----------------------------------------------
+// Test 39: touying-equation with 2 pauses then waypoint + from-wp
+// -----------------------------------------------
+
+== Multi-pause Equation then Waypoint
+
+#touying-equation(
+ `
+ f(x) &= pause x^2 + 2x + 1 \
+ &= pause (x + 1)^2
+`,
+)
+
+#waypoint(<after-multi-eq>)
+
+#uncover(from-wp(<after-multi-eq>))[Visible only after all equation pauses.]
+
+#only(get-first(<after-multi-eq>))[Exactly on the waypoint subslide.]
+
+
+// -----------------------------------------------
+// Test 40: CeTZ reducer then waypoint
+// -----------------------------------------------
+
+== CeTZ then Waypoint
+
+#cetz-canvas({
+ import cetz.draw: *
+ rect((0, 0), (4, 3))
+ (pause,)
+ circle((2, 1.5), radius: 1)
+})
+
+#waypoint(<after-cetz>)
+
+#uncover(<after-cetz>)[Visible after CeTZ animation.]
+
+#only(from-wp(<after-cetz>))[From-wp after CeTZ.]
+
+
+// -----------------------------------------------
+// Test 41: Fletcher reducer then waypoint
+// -----------------------------------------------
+
+== Fletcher then Waypoint
+
+#fletcher-diagram(
+ node-stroke: .1em,
+ spacing: 3em,
+ node((0, 0), `A`, radius: 1.5em),
+ edge(`go`, "-|>"),
+ pause,
+ node((1, 0), `B`, radius: 1.5em),
+)
+
+#waypoint(<after-fletcher>)
+
+#uncover(<after-fletcher>)[Visible after Fletcher animation.]
+
+#only(from-wp(<after-fletcher>))[From-wp after Fletcher.]
+
+
+// -----------------------------------------------
+// Test 42: Waypoint inside CeTZ reducer
+// -----------------------------------------------
+
+== Waypoint inside CeTZ
+
+#cetz-canvas({
+ import cetz.draw: *
+ rect((0, 0), (4, 3))
+ (waypoint(<cetz-mid>, advance: false),)
+ (pause,)
+ circle((2, 1.5), radius: 1)
+ (waypoint(<cetz-end>),)
+ circle((2, 1.5), radius: 0.1, fill: red, stroke: none)
+})
+
+#uncover(<cetz-mid>)[Visible from start (no-advance waypoint inside CeTZ).]
+
+#uncover(<cetz-end>)[Visible when Cetz Red Dot appears.]
+
+#only(from-wp(<cetz-end>))[From-wp referencing waypoint inside CeTZ.]
+
+
+// -----------------------------------------------
+// Test 43: Waypoint inside Fletcher reducer
+// -----------------------------------------------
+
+== Waypoint inside Fletcher
+
+#fletcher-diagram(
+ node-stroke: .1em,
+ spacing: 3em,
+ node((0, 0), `A`, radius: 1.5em),
+ waypoint(<fl-mid>, advance: false),
+ pause,
+ edge(`go`, "-|>"),
+ node((1, 0), `B`, radius: 1.5em),
+ waypoint(<fl-after-b>),
+ edge((1, 0), (0, 0), `back`, "|->", bend: 50deg),
+)
+
+#uncover(<fl-mid>)[Visible from start (no-advance waypoint in Fletcher).]
+
+#uncover(get-last(<fl-mid>))[Visible after node B appears.]
+
+#only(from-wp(<fl-after-b>))[From-wp referencing back waypoint inside Fletcher.]
+
+
+// -----------------------------------------------
+// Test 44: Waypoint inside reducer + outer waypoint interaction
+// -----------------------------------------------
+
+== Reducer Waypoint with Outer Reference
+
+#cetz-canvas({
+ import cetz.draw: *
+ rect((0, 0), (4, 3))
+ (pause,)
+ line((0, 0), (4, 3))
+ (waypoint(<inner-cetz>),)
+ circle((2, 1.5), radius: 0.5)
+ (waypoint(<after-inner-cetz>),)
+ rect((0, 0), (4, 3), stroke: red)
+})
+
+#waypoint(<after-inner-cetz>) //noop bc inside already declares it.
+
+Text after CeTZ. Box should be red now.
+
+#uncover(from-wp(<inner-cetz>))[From inner CeTZ waypoint (subslide 3).]
+
+#uncover(from-wp(
+ <after-inner-cetz>,
+))[From outer waypoint after CeTZ (subslide 4).]
+
+// ------------------------------------------------
+// Test 45: get-last inside from-wp resolves correctly
+// -----------------------------------------------
+// <gl-phase> spans subslides 1-2 (pause inside its range), <gl-after> at 3.
+// from-wp(get-last(<gl-phase>)) should mean "from subslide 2 onward".
+// BUG before fix: resolved to (beginning: 1) instead of (beginning: 2).
+
+== get-last inside from-wp
+
+#waypoint(<gl-phase>, advance: false)
+Phase content.
+#pause
+More phase content.
+#waypoint(<gl-after>)
+After content.
+
+#only(get-first(<gl-phase>))[First of phase only (subslide 1).]
+#only(get-last(<gl-phase>))[Last of phase only (subslide 2).]
+#uncover(from-wp(get-last(
+ <gl-phase>,
+)))[From last of phase onward (subslides 2-3).]
+
+// -----------------------------------------------
+// Test 46: get-first inside from-wp (control — should behave like bare label)
+// -----------------------------------------------
+
+== get-first inside from-wp
+
+#waypoint(<gf-phase>, advance: false)
+Phase content.
+#pause
+More phase content.
+#waypoint(<gf-after>)
+After content.
+
+#uncover(from-wp(get-first(
+ <gf-phase>,
+)))[From first of phase onward (subslides 1-3).]
+#uncover(from-wp(<gf-phase>))[From phase onward (subslides 1-3, same).]
+
+// -----------------------------------------------
+// Test 47: get-last inside until-wp
+// -----------------------------------------------
+
+== get-last inside until-wp
+
+#waypoint(<gu-a>, advance: false)
+Part A.
+#pause
+More A.
+#waypoint(<gu-b>)
+Part B.
+
+#uncover(until-wp(get-last(<gu-a>)))[Until last of A (subslide 1 only).]
+#uncover(from-wp(get-last(<gu-a>)))[From last of A onward (subslides 2-3).]
+
+// -----------------------------------------------
+// Test 48: Waypoint with start: int
+// -----------------------------------------------
+
+== Start Int
+
+// Without start, waypoints land sequentially.
+// With start: 5, the waypoint is placed at subslide 5 regardless of position.
+
+#waypoint(<si-a>, advance: false)
+Phase A.
+
+#waypoint(<si-jump>, start: 3)
+Jumped to subslide 3.
+
+#only(<si-a>)[During A (subslides 1-2).]
+#only(<si-jump>)[During jump (subslide 3).]
+#only(get-first(<si-a>))[Exactly subslide 1.]
+#only(get-first(<si-jump>))[Exactly subslide 3.]
+
+// -----------------------------------------------
+// Test 50: Waypoint with start: <label>
+// -----------------------------------------------
+
+== Start Label
+
+// <sl-ref> is declared normally at subslide 2.
+// <sl-alias> uses start: <sl-ref> to share the same starting subslide.
+
+#waypoint(<sl-a>, advance: false)
+Phase A.
+
+#waypoint(<sl-ref>)
+Reference phase.
+
+#waypoint(<sl-alias>, start: <sl-ref>)
+ALIAS: With reference start.
+
+#only(<sl-a>)[During A (subslide 1).]
+#only(<sl-ref>)[During ref (subslide 2).]
+#only(get-first(<sl-alias>))[Alias first = ref first (subslide 2).]
+
+// -----------------------------------------------
+// Test 51: Waypoint start: <label> chain
+// -----------------------------------------------
+
+== Start Label Chain
+
+// <ch-a> is auto at subslide 2.
+// <ch-b> references <ch-a>, so also at subslide 2.
+// <ch-c> references <ch-b>, so also at subslide 2.
+Intro.
+#pause
+Content on subslide 2.
+
+#waypoint(<ch-a>, advance: false)
+A content.
+#waypoint(<ch-c>, start: <ch-b>)
+C content.
+#pause
+C continued.
+#waypoint(<ch-b>, start: <ch-a>)
+B content.
+
+#only(get-first(<ch-a>))[A first = 2.]
+#only(get-first(<ch-b>))[B first = 2 (via A).]
+#only(<ch-c>)[C = 2,3 (via B via A).]
+
+// -----------------------------------------------
+// Test 52: Waypoint start: int backward jump
+// -----------------------------------------------
+
+== Start Int/Label Backward
+
+// A waypoint can jump backward to an earlier subslide.
+
+#waypoint(<bk-a>, advance: false)
+Phase A.
+
+#pause
+Subslide 2.
+
+#pause
+Subslide 3.
+
+#waypoint(<bk-back>, start: 1)
+Back to 1. (with A)
+
+#pause
+Now at 2.
+
+#waypoint(<bk-again>, start: <bk-a>)
+1. #pause 2. #pause 3.
+
+#only(<bk-back>)[During first back (1,2).]
+#only(get-first(<bk-back>))[Back-jump lands on subslide 1.]
+
+== Test Alternatives with overlapping waypoints
+
+#waypoint(<alt-a>, advance: false)
+Phase A1.
+#pause
+Phase A2.
+
+#waypoint(<alt-b>, start: 2)
+Phase B1 (at A2).
+#pause
+Phase B2.
+#pause
+Phase B3.
+
+
+
+#only(<alt-a>)[Only during A.]
+
+#only(<alt-b>)[Only during B.]
+
+#alternatives(at: (<alt-a>, <alt-b>))[Alt A][Alt B]
--- /dev/null
+#import "../../../lib.typ": *
+#import themes.university: *
+#import "@preview/cetz:0.5.0"
+
+// cetz bindings for touying
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+
+#show: university-theme.with(aspect-ratio: "16-9")
+
+== CeTZ Animation
+
+// cetz animation with pause
+#slide[
+ #cetz-canvas({
+ import cetz.draw: *
+ rect((0, 0), (5, 5))
+ (pause,)
+ rect((0, 0), (1, 1))
+ (pause,)
+ line((0, 0), (2.5, 2.5), name: "line")
+ })
+]
+
+== only and uncover in Cetz (callback style)
+
+#slide(repeat: 3, self => [
+ #let (uncover, only, alternatives) = utils.methods(self)
+
+ #cetz.canvas({
+ import cetz.draw: *
+ let uncover = uncover.with(cover-fn: hide.with(bounds: true))
+
+ rect((0, 0), (5, 5))
+
+ uncover("2-3", {
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+ })
+
+ only(3, line((0, 0), (2.5, 2.5), name: "line"))
+ })
+])
+
+== Cetz with native only/uncover
+
+// Same as above but using the reducer — should produce identical output.
+#slide[
+
+ #cetz-canvas({
+ import cetz.draw: *
+ rect((0, 0), (5, 5))
+ (
+ uncover("2-3", {
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+ }),
+ )
+ (only(3, line((0, 0), (2.5, 2.5), name: "line")),)
+ (
+ only(from-wp(<wp2>), line(
+ (0, 5),
+ (2.5, 2.5),
+ stroke: red,
+ name: "line-alt",
+ )),
+ )
+ })
+ #meanwhile
+ #waypoint(<wp1>, advance: false)
+ Normal Content.
+ #waypoint(<wp2>)
+ My Line.
+ #waypoint(<wp3>)
+ Also My line?
+]
+
+
+== Cetz with native alternatives
+
+#slide[
+ #cetz-canvas({
+ import cetz.draw: *
+ rect((0, 0), (5, 5))
+
+ (
+ alternatives(start: 1, rect((0, 0), (1, 1)), rect((1, 1), (2, 2)), rect(
+ (2, 2),
+ (3, 3),
+ )),
+ )
+
+ (
+ alternatives(start: 2, line((0, 0), (2.5, 2.5), name: "line"), line(
+ (0, 5),
+ (2.5, 2.5),
+ name: "line-alt",
+ )),
+ )
+ })
+]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+#import "@preview/codly:1.3.0": *
+#import "@preview/codly-languages:0.1.10": *
+#show: codly-init.with()
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ // Enable Codly with common preamble
+ config-common(preamble: {
+ codly(languages: codly-languages)
+ }),
+)
+
+== First slide
+
+```rust
+pub fn main() {
+ println!("Hello, world!");
+}
+```
--- /dev/null
+#import "/lib.typ": *
+#import themes.university: *
+#import "@preview/fletcher:0.5.8" as fletcher: edge, node
+
+// fletcher bindings for touying
+#let fletcher-diagram = touying-reducer.with(
+ reduce: fletcher.diagram,
+ cover: fletcher.hide,
+)
+
+#show: university-theme.with(aspect-ratio: "16-9")
+
+== Fletcher Animation
+
+#slide[
+ Fletcher in Touying:
+
+ #fletcher-diagram(
+ node-stroke: .1em,
+ node-fill: gradient.radial(
+ blue.lighten(80%),
+ blue,
+ center: (30%, 20%),
+ radius: 80%,
+ ),
+ spacing: 4em,
+ edge((-1, 0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
+ node((0, 0), `reading`, radius: 2em),
+ edge((0, 0), (0, 0), `read()`, "--|>", bend: 130deg),
+ pause,
+ edge(`read()`, "-|>"),
+ node((1, 0), `eof`, radius: 2em),
+ pause,
+ edge(`close()`, "-|>"),
+ node((2, 0), `closed`, radius: 2em, extrude: (-2.5, 0)),
+ edge((0, 0), (2, 0), `close()`, "-|>", bend: -40deg),
+ )
+]
+
+== Fletcher with native uncover and only
+
+// Tests fn-wrappers inside the reducer: uncover uses fletcher.hide,
+// only produces no output when hidden.
+#slide[
+ #fletcher-diagram(
+ node-stroke: .1em,
+ spacing: 4em,
+ node((0, 0), [A], radius: 2em),
+ pause,
+ uncover("1-2", edge((0, 0), (1, 0), "-|>", stroke: blue)),
+ uncover("2-", node((1, 0), [B], radius: 2em)),
+ only(3, node((0, 1), [tmp], radius: 1em, fill: orange)),
+ )
+]
+
+// == Fletcher with pause after uncover
+
+// // Tests that pause lands after fn-wrapper range, not before.
+// #slide[
+// #fletcher-diagram(
+// node-stroke: .1em,
+// spacing: 4em,
+// node((0, 0), [X], radius: 2em),
+// uncover("2-3", edge((0, 0), (1, 0), "-|>")),
+// uncover("2-3", node((1, 0), [Y], radius: 2em)),
+// pause,
+// edge((1, 0), (2, 0), "-|>"),
+// node((2, 0), [Z], radius: 2em),
+// )
+// ]
+
+== Fletcher with waypoints
+
+// Tests waypoint labels inside the reducer with uncover/only.
+#slide[
+ #fletcher-diagram(
+ node-stroke: .1em,
+ spacing: 4em,
+ node((0, 0), [Origin], radius: 2em),
+ waypoint(<fl-step>, advance: false),
+ uncover(<fl-step>, edge((0, 0), (1, 0), "-|>")),
+ uncover(<fl-step>, node((1, 0), [W], radius: 2em)),
+ only(<fl-done>, node(
+ (1, 1),
+ [Done],
+ radius: 1.5em,
+ fill: green.lighten(60%),
+ )),
+ )
+]
+
+== Fletcher with alternatives
+
+// Tests alternatives: node swaps label per subslide.
+#slide[
+ #fletcher-diagram(
+ node-stroke: .1em,
+ spacing: 4em,
+ node((0, 0), [Start], radius: 2em),
+ edge("-|>"),
+ alternatives(
+ node((1, 0), [Phase 1], radius: 2em),
+ node((1, 0), [Phase 2], radius: 2em),
+ ),
+ )
+]
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+#import "@preview/mitex:0.2.6": *
+
+#show: simple-theme
+
+= Math Equation Animations
+
+== Simple Animation
+
+Touying equation with pause:
+
+// TODO: fix touying mitex bug
+$
+ f(x) & = pause x^2 + 2x + 1 \
+ & = pause (x + 1)^2 \
+$
+
+#meanwhile
+
+Touying equation is very simple.
--- /dev/null
+#import "/lib.typ": *
+#import themes.default: *
+#import "@preview/pinit:0.2.2": *
+
+#set text(size: 20pt, ligatures: false)
+#show heading: set text(weight: "regular")
+#show heading: set block(above: 1.4em, below: 1em)
+#show heading.where(level: 1): set text(size: 1.5em)
+
+// Useful functions
+#let crimson = rgb("#c00000")
+#let greybox(..args, body) = rect(
+ fill: luma(95%),
+ stroke: 0.5pt,
+ inset: 0pt,
+ outset: 10pt,
+ ..args,
+ body,
+)
+#let redbold(body) = {
+ set text(fill: crimson, weight: "bold")
+ body
+}
+#let blueit(body) = {
+ set text(fill: blue)
+ body
+}
+
+#show: default-theme.with(aspect-ratio: "4-3")
+
+// Main body
+#slide[
+ #set heading(offset: 0)
+
+ = Asymptotic Notation: $O$
+
+ Use #pin("h1")asymptotic notations#pin("h2") to describe asymptotic efficiency of algorithms.
+ (Ignore constant coefficients and lower-order terms.)
+
+ #pause
+
+ #greybox[
+ Given a function $g(n)$, we denote by $O(g(n))$ the following *set of functions*:
+ #redbold(
+ ${f(n): "exists" c > 0 "and" n_0 > 0, "such that" f(n) <= c dot g(n) "for all" n >= n_0}$,
+ )
+ ]
+
+ #pinit-highlight("h1", "h2")
+
+ #pause
+
+ $f(n) = O(g(n))$: #pin(1)$f(n)$ is *asymptotically smaller* than $g(n)$.#pin(2)
+
+ #pause
+
+ $f(n) redbold(in) O(g(n))$: $f(n)$ is *asymptotically* #redbold[at most] $g(n)$.
+
+ #only("4-", pinit-line(
+ stroke: 3pt + crimson,
+ start-dy: -0.25em,
+ end-dy: -0.25em,
+ 1,
+ 2,
+ ))
+
+ #pause
+
+ #block[Insertion Sort as an #pin("r1")example#pin("r2"):]
+
+ - Best Case: $T(n) approx c n + c' n - c''$ #pin(3)
+ - Worst case: $T(n) approx c n + (c' \/ 2) n^2 - c''$ #pin(4)
+
+ #pinit-rect("r1", "r2")
+
+ #pause
+
+ #pinit-place(3, dx: 15pt, dy: -15pt)[#redbold[$T(n) = O(n)$]]
+ #pinit-place(4, dx: 15pt, dy: -15pt)[#redbold[$T(n) = O(n)$]]
+
+ #pause
+
+ #blueit[Q: Is $n^(3) = O(n^2)$#pin("que")? How to prove your answer#pin("ans")?]
+
+ #pause
+
+ #pinit-point-to("que", fill: crimson, redbold[No.])
+ #pinit-point-from("ans", body-dx: -150pt)[
+ Show that the equation $(3/2)^n >= c$ \
+ has infinitely many solutions for $n$.
+ ]
+]
--- /dev/null
+#import "/lib.typ": *
+#import themes.university: *
+#import "@preview/theorion:0.6.0": *
+#import cosmos.clouds: *
+#show: show-theorion
+
+#show: university-theme.with(
+ config-common(frozen-counters: (theorem-counter,)), // freeze theorem counter for animation
+)
+
+= Theorems
+
+== Prime numbers
+
+#definition[
+ A natural number is called a #highlight[_prime number_] if it is greater
+ than 1 and cannot be written as the product of two smaller natural numbers.
+]
+
+#pause
+
+#example[
+ The numbers $2$, $3$, and $17$ are prime.
+ @cor_largest_prime shows that this list is not exhaustive!
+]
+
+#theorem(title: "Euclid")[
+ There are infinitely many primes.
+]
+#pagebreak(weak: true)
+#proof[
+ Suppose to the contrary that $p_1, p_2, dots, p_n$ is a finite enumeration
+ of all primes. Set $P = p_1 p_2 dots p_n$. Since $P + 1$ is not in our list,
+ it cannot be prime. Thus, some prime factor $p_j$ divides $P + 1$. Since
+ $p_j$ also divides $P$, it must divide the difference $(P + 1) - P = 1$, a
+ contradiction.
+]
+
+#corollary[
+ There is no largest prime number.
+] <cor_largest_prime>
+#corollary[
+ There are infinitely many composite numbers.
+]
+
+#theorem[
+ There are arbitrarily long stretches of composite numbers.
+]
+
+#proof[
+ For any $n > 2$, consider $ n! + 2, quad n! + 3, quad ..., quad n! + n $
+]
--- /dev/null
+// Issue: slides after table not rendering
+// https://github.com/touying-typ/touying/issues/164
+
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Section
+
+== First Slide
+
+#set text(red)
+test
+#set text(black)
+
+== I Should Be Rendered
+
+This slide should render.
--- /dev/null
+// Issue: ratio in margin
+// https://github.com/touying-typ/touying/issues/203
+
+#import "/lib.typ": *
+#import "@preview/cetz:0.5.0"
+#import themes.university: *
+
+#show: university-theme.with(
+ config-page(
+ margin: (top: 2.25em, bottom: 1em, x: (100% - 30em) / 2),
+ ),
+)
+
+= Title
+
+== Subtitle
+
+Content
--- /dev/null
+// Issue: meanwhile in cetz
+// https://github.com/touying-typ/touying/issues/204
+
+#import "/lib.typ": *
+#import "@preview/cetz:0.5.0"
+#import themes.default: *
+
+// Cetz bindings for touying.
+#let cetz-canvas = touying-reducer.with(
+ reduce: cetz.canvas,
+ cover: cetz.draw.hide.with(bounds: true),
+)
+
+#show: default-theme.with(aspect-ratio: "16-9")
+
+#slide[
+ Cetz in Touying:
+
+ #cetz-canvas({
+ import cetz.draw: *
+
+ rect((0, 0), (5, 5))
+
+ (pause,)
+
+ rect((0, 0), (1, 1))
+ rect((1, 1), (2, 2))
+ rect((2, 2), (3, 3))
+
+ (meanwhile,)
+
+ line((0, 0), (2.5, 2.5), name: "line")
+ })
+]
--- /dev/null
+// Issue: Special case for styled content on the first slide?
+// https://github.com/touying-typ/touying/issues/216
+
+#import "/lib.typ": *
+#import themes.university: *
+#show: university-theme
+
+== Slide 1
+
+first slide before styled
+#[
+ #set text(fill: red)
+ first slide styled
+]
+first slide after styled
--- /dev/null
+// Test: does a box(0,0) in the middle set page create a blank page?
+#set page(width: 200pt, height: 100pt)
+
+Page 1 content
+
+#set page(width: 200pt, height: 100pt, fill: none)
+// Only a zero-size box - does this create page 2?
+#box(width: 0pt, height: 0pt)
+
+#set page(width: 200pt, height: 100pt, fill: none)
+Page 3 content
--- /dev/null
+// Issue: Strange behaviour of dewdrop theme with new-section-slide-fn
+// https://github.com/touying-typ/touying/issues/239
+// When a custom new-section-slide-fn body contains only context elements
+// (lazily evaluated), Typst fails to properly anchor the page layout in the
+// first pass. This caused hidden headings from subsequent slides to receive
+// incorrect page assignments, making context queries return wrong headings.
+
+#import "/lib.typ": *
+#import themes.dewdrop: *
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ config-common(
+ new-section-slide-fn: (
+ section => {
+ touying-slide-wrapper(self => {
+ touying-slide(
+ self: self,
+ {
+ utils.display-current-heading(level: 1)
+ components.mini-slides(
+ fill: self.colors.primary,
+ display-subsection: false,
+ )
+ },
+ )
+ })
+ }
+ ),
+ ),
+ navigation: none,
+)
+
+= Subtitle 1
+== subsub1
+inner1
+
+= Subtitle 2
+== subsub2
+inner2
--- /dev/null
+// Issue: meanwhile does not work in grids
+// https://github.com/touying-typ/touying/issues/259
+
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(aspect-ratio: "16-9")
+
+= Title
+
+== Grid with meanwhile
+
+#grid(columns: 2, gutter: 1em)[
+ Hello
+
+ #pause
+
+ world
+][
+ #meanwhile
+ this should always show up
+]
+
+== Box with meanwhile
+
+#box[
+ left
+
+ #pause
+
+ l1
+]
+#box[
+ #meanwhile
+ right
+]
--- /dev/null
+// Issue: empty slide after all show-rules
+// https://github.com/touying-typ/touying/issues/312
+
+#import "/lib.typ": *
+#import themes.stargazer: *
+
+#show: stargazer-theme.with(
+ aspect-ratio: "16-9",
+ config-info(title: [Title]),
+)
+
+== Frame Title
+There should be only 1 contents slide.
+
+#show: appendix
+#counter(heading).update(0) // This line
+
+== Backup Slide
+Appendix.
--- /dev/null
+# generated by tytanic, do not edit
+
+diff/**
+out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.aqua: *
+
+#show: aqua-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#title-slide()
+
+#outline-slide()
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+
+#focus-slide[
+ Another variant with primary color in background...
+]
+
+== Summary
+
+#slide(self => [
+ #align(center + horizon)[
+ #set text(size: 3em, weight: "bold", fill: self.colors.primary)
+ THANKS FOR ALL
+ ]
+])
+
+
--- /dev/null
+# generated by tytanic, do not edit
+
+diff/**
+out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.dewdrop: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ navigation: none,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+$ x_(n+1) = (x_n + a / x_n) / 2 $
+
+== Subsection A.2
+
+A slide without a title but with *important* infos
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+# generated by tytanic, do not edit
+
+diff/**
+out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.dewdrop: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ navigation: "mini-slides",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+$ x_(n+1) = (x_n + a / x_n) / 2 $
+
+== Subsection A.2
+
+A slide without a title but with *important* infos
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+#import "/lib.typ": *
+#import themes.dewdrop: *
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ navigation: none,
+ config-common(
+ receive-body-for-new-section-slide-fn: true,
+ ),
+)
+
+= Foo
+
+#lorem(50)
--- /dev/null
+# generated by tytanic, do not edit
+
+diff/**
+out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.dewdrop: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: dewdrop-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ navigation: "sidebar",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+$ x_(n+1) = (x_n + a / x_n) / 2 $
+
+== Subsection A.2
+
+A slide without a title but with *important* infos
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+# generated by tytanic, do not edit
+
+diff/**
+out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.metropolis: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: metropolis-theme.with(
+ aspect-ratio: "16-9",
+ footer: self => self.info.institution,
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ logo: emoji.city,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide(level: 1)
+
+= First Section
+
+---
+
+A slide without a title but with some *important* information.
+
+== A long long long long long long long long long long long long long long long long long long long long long long long long Title
+
+=== sdfsdf
+
+A slide with equation:
+
+$ x_(n+1) = (x_n + a / x_n) / 2 $
+
+#lorem(200)
+
+= Second Section
+
+#focus-slide[
+ Wake up!
+]
+
+== Simple Animation
+
+We can use `#pause` to #pause display something later.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to display other content synchronously.
+
+#speaker-note[
+ + This is a speaker note.
+ + You won't see it unless you use `config-common(show-notes-on-second-screen: right)`
+]
+
+#show: appendix
+
+= Appendix
+
+---
+
+Please pay attention to the current slide number.
--- /dev/null
+# generated by tytanic, do not edit
+
+diff/**
+out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.simple: *
+
+#show: simple-theme.with(
+ aspect-ratio: "16-9",
+ footer: [Simple slides],
+)
+
+#title-slide[
+ = Keep it simple!
+ #v(2em)
+
+ Alpha #footnote[Uni Augsburg] #h(1em)
+ Bravo #footnote[Uni Bayreuth] #h(1em)
+ Charlie #footnote[Uni Chemnitz] #h(1em)
+
+ July 23
+]
+
+== First slide
+
+#lorem(20)
+
+#focus-slide[
+ _Focus!_
+
+ This is very important.
+]
+
+= Let's start a new section!
+
+== Dynamic slide
+
+Did you know that...
+
+#pause
+
+...you can see the current section at the top of the slide?
--- /dev/null
+# generated by tytanic, do not edit
+
+diff/**
+out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.stargazer: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: stargazer-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Stargazer in Touying: Customize Your Slide Title Here],
+ subtitle: [Customize Your Slide Subtitle Here],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide()
+
+#outline-slide()
+
+= Section A
+
+== Subsection A.1
+
+#tblock(title: [Theorem])[
+ A simple theorem.
+
+ $ x_(n+1) = (x_n + a / x_n) / 2 $
+]
+
+== Subsection A.2
+
+A slide without a title but with *important* information.
+
+= Section B
+
+== Subsection B.1
+
+#lorem(80)
+
+#focus-slide[
+ Wake up!
+]
+
+== Subsection B.2
+
+We can use `#pause` to #pause display something later.
+
+#pause
+
+Just like this.
+
+#meanwhile
+
+Meanwhile, #pause we can also use `#meanwhile` to #pause display other content synchronously.
+
+#show: appendix
+
+= Appendix
+
+== Appendix
+
+Please pay attention to the current slide number.
--- /dev/null
+# generated by tytanic, do not edit
+
+diff/**
+out/**
--- /dev/null
+#import "/lib.typ": *
+#import themes.university: *
+
+#import "@preview/numbly:0.1.0": numbly
+
+#show: university-theme.with(
+ aspect-ratio: "16-9",
+ config-info(
+ title: [Title],
+ subtitle: [Subtitle],
+ author: [Authors],
+ date: datetime.today(),
+ institution: [Institution],
+ logo: emoji.school,
+ ),
+)
+
+#set heading(numbering: numbly("{1}.", default: "1.1"))
+
+#title-slide(authors: ([Author A], [Author B]))
+
+= The Section
+
+== Slide Title
+
+#lorem(40)
+
+#focus-slide[
+ Another variant with primary color in background...
+]
+
+#matrix-slide[
+ left
+][
+ middle
+][
+ right
+]
+
+#matrix-slide(columns: 1)[
+ top
+][
+ bottom
+]
+
+#matrix-slide(columns: (1fr, 2fr, 1fr), ..(lorem(8),) * 9)
--- /dev/null
+#import "../src/exports.typ": *
+
+/// Default slide function for the presentation.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - repeat (int, auto): The number of subslides. Default is `auto`, which means touying will automatically calculate the number of subslides.
+///
+/// The `repeat` argument is necessary when you use `#slide(repeat: 3, self => [ .. ])` style code to create a slide. The callback-style `uncover` and `only` cannot be detected by touying automatically.
+///
+/// - setting (function): The setting of the slide. You can use it to add some set/show rules for the slide.
+///
+/// - composer (function, array): The composer of the slide. You can use it to set the layout of the slide.
+///
+/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` to split the slide into three parts. The first and the last parts will take 1/4 of the slide, and the second part will take 1/2 of the slide.
+///
+/// If you pass a non-function value like `(1fr, 2fr, 1fr)`, it will be assumed to be the first argument of the `cols` function.
+///
+/// The `cols` function is a simple wrapper of the `grid` function. It means you can use the `grid.cell(colspan: 2, ..)` to make the cell take 2 columns.
+///
+/// For example, `#slide(composer: 2)[A][B][#grid.cell(colspan: 2)[Footer]]` will make the `Footer` cell take 2 columns.
+///
+/// If you want to customize the composer, you can pass a function to the `composer` argument. The function should receive the contents of the slide and return the content of the slide, like `#slide(composer: grid.with(columns: 2))[A][B]`.
+///
+/// - bodies (content): The contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide.
+#let slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ let header(self) = {
+ place(
+ center + top,
+ dy: .5em,
+ rect(
+ width: 100%,
+ height: 1.8em,
+ fill: self.colors.primary,
+ align(
+ left + horizon,
+ h(1.5em)
+ + text(fill: white, utils.call-or-display(self, self.store.header)),
+ ),
+ ),
+ )
+ place(left + top, line(
+ start: (30%, 0%),
+ end: (27%, 100%),
+ stroke: .5em + white,
+ ))
+ }
+ let footer(self) = {
+ set text(size: 0.8em)
+ place(right, dx: -5%, utils.call-or-display(self, utils.call-or-display(
+ self,
+ self.store.footer,
+ )))
+ }
+ let self = utils.merge-dicts(
+ self,
+ config-page(
+ header: header,
+ footer: footer,
+ ),
+ )
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: setting,
+ composer: composer,
+ ..bodies,
+ )
+})
+
+
+/// Title slide for the presentation. You should update the information in the `config-info` function. You can also pass the information directly to the `title-slide` function.
+///
+/// Example:
+///
+/// ```typst
+/// #show: aqua-theme.with(
+/// config-info(
+/// title: [Title],
+/// ),
+/// )
+///
+/// #title-slide(subtitle: [Subtitle], extra: [Extra information])
+/// ```
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - extra (content, none): The extra information you want to display on the title slide.
+#let title-slide(config: (:), extra: none, ..args) = touying-slide-wrapper(
+ self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(
+ background: utils.call-or-display(self, self.store.background),
+ margin: (x: 0em, top: 30%, bottom: 0%),
+ ),
+ config,
+ )
+ let info = self.info + args.named()
+ let body = {
+ set align(center)
+ stack(
+ spacing: 3em,
+ if info.title != none {
+ text(
+ size: 48pt,
+ weight: "bold",
+ fill: self.colors.primary,
+ info.title,
+ )
+ },
+ if info.author != none {
+ text(
+ fill: self.colors.primary-light,
+ size: 28pt,
+ weight: "regular",
+ info.author,
+ )
+ },
+ if info.date != none {
+ text(
+ fill: self.colors.primary-light,
+ size: 20pt,
+ weight: "regular",
+ utils.display-info-date(self),
+ )
+ },
+ if extra != none {
+ text(
+ fill: self.colors.primary-light,
+ size: 20pt,
+ weight: "regular",
+ extra,
+ )
+ },
+ )
+ }
+ touying-slide(self: self, body)
+ },
+)
+
+
+/// Outline slide for the presentation.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - leading (length): The leading of paragraphs in the outline. Default is `50pt`.
+#let outline-slide(config: (:), leading: 50pt) = touying-slide-wrapper(self => {
+ set text(size: 30pt, fill: self.colors.primary)
+ set par(leading: leading)
+
+ let body = {
+ grid(
+ columns: (1fr, 1fr),
+ rows: 1fr,
+ align(
+ center + horizon,
+ {
+ set par(leading: 20pt)
+ context {
+ if text.lang == "zh" {
+ text(
+ size: 80pt,
+ weight: "bold",
+ [#text(size: 36pt)[CONTENTS]\ 目录],
+ )
+ } else {
+ text(
+ size: 48pt,
+ weight: "bold",
+ [CONTENTS],
+ )
+ }
+ }
+ },
+ ),
+ align(
+ left + horizon,
+ {
+ set par(leading: leading)
+ set text(weight: "bold")
+ components.custom-progressive-outline(
+ level: none,
+ depth: 1,
+ numbered: (true,),
+ )
+ },
+ ),
+ )
+ }
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(
+ background: utils.call-or-display(self, self.store.background),
+ margin: 0em,
+ ),
+ )
+ touying-slide(self: self, config: config, body)
+})
+
+
+/// New section slide for the presentation. You can update it by updating the `new-section-slide-fn` argument for `config-common` function.
+///
+/// Example: `config-common(new-section-slide-fn: new-section-slide.with(numbered: false))`
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - level (int): The level of the heading.
+///
+/// - body (content): The body of the section. It will be passed by touying automatically.
+#let new-section-slide(config: (:), level: 1, body) = touying-slide-wrapper(
+ self => {
+ let slide-body = {
+ stack(
+ dir: ttb,
+ spacing: 12%,
+ align(
+ center,
+ text(
+ fill: self.colors.primary,
+ size: 166pt,
+ utils.display-current-heading-number(level: level),
+ ),
+ ),
+ align(
+ center,
+ text(
+ fill: self.colors.primary,
+ size: 60pt,
+ weight: "bold",
+ utils.display-current-heading(level: level, numbered: false),
+ ),
+ ),
+ )
+ body
+ }
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ margin: (left: 0%, right: 0%, top: 20%, bottom: 0%),
+ background: utils.call-or-display(self, self.store.background),
+ ),
+ )
+ touying-slide(self: self, config: config, slide-body)
+ },
+)
+
+
+/// Focus on some content.
+///
+/// Example: `#focus-slide[Wake up!]`
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more configurations, you can use `utils.merge-dicts` to merge them.
+#let focus-slide(config: (:), body) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(fill: self.colors.primary, margin: 2em),
+ )
+ set text(fill: self.colors.neutral-lightest, size: 2em, weight: "bold")
+ touying-slide(self: self, config: config, align(horizon + center, body))
+})
+
+
+/// Touying aqua theme.
+///
+/// Example:
+///
+/// ```typst
+/// #show: aqua-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))
+/// ```
+///
+/// Consider using:
+///
+/// ```typst
+/// #set text(font: "Fira Sans", weight: "light", size: 20pt)
+/// #show math.equation: set text(font: "Fira Math")
+/// #set strong(delta: 100)
+/// #set par(justify: true)
+/// ```
+///
+/// The default colors:
+///
+/// ```typst
+/// config-colors(
+/// primary: rgb("#003F88"),
+/// primary-light: rgb("#2159A5"),
+/// primary-lightest: rgb("#F2F4F8"),
+/// neutral-lightest: rgb("#FFFFFF")
+/// )
+/// ```
+///
+/// - aspect-ratio (ratio): The aspect ratio of the slides. Default is `16-9`.
+///
+/// - header (content): The header of the slides. Default is `self => utils.display-current-heading(depth: self.slide-level)`.
+///
+/// - footer (content): The footer of the slides. Default is `context utils.slide-counter.display()`.
+#let aqua-theme(
+ aspect-ratio: "16-9",
+ header: self => utils.display-current-heading(depth: self.slide-level),
+ footer: context utils.slide-counter.display(),
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+ set heading(numbering: "1.1")
+ show heading.where(level: 1): set heading(numbering: "01")
+
+ show: touying-slides.with(
+ config-page(
+ ..utils.page-args-from-aspect-ratio(aspect-ratio),
+ margin: (x: 2em, top: 3.5em, bottom: 2em),
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ ),
+ config-methods(
+ init: (self: none, body) => {
+ show heading: set text(fill: self.colors.primary-light)
+
+ body
+ },
+ alert: utils.alert-with-primary-color,
+ ),
+ config-colors(
+ primary: rgb("#003F88"),
+ primary-light: rgb("#2159A5"),
+ primary-lightest: rgb("#F2F4F8"),
+ neutral-lightest: rgb("#FFFFFF"),
+ ),
+ // save the variables for later use
+ config-store(
+ align: align,
+ header: header,
+ footer: footer,
+ background: self => {
+ let (page-width, _) = utils.get-page-dimensions(self)
+ let r = if (
+ self.at("show-notes-on-second-screen", default: none) == none
+ ) { 1.0 } else { 0.5 }
+ let bias1 = -page-width * (1 - r)
+ let bias2 = -page-width * 2 * (1 - r)
+ place(left + top, dx: -15pt, dy: -26pt, circle(
+ radius: 40pt,
+ fill: self.colors.primary,
+ ))
+ place(left + top, dx: 65pt, dy: 12pt, circle(
+ radius: 21pt,
+ fill: self.colors.primary,
+ ))
+ place(left + top, dx: r * 3%, dy: 15%, circle(
+ radius: 13pt,
+ fill: self.colors.primary,
+ ))
+ place(left + top, dx: r * 2.5%, dy: 27%, circle(
+ radius: 8pt,
+ fill: self.colors.primary,
+ ))
+ place(right + bottom, dx: 15pt + bias2, dy: 26pt, circle(
+ radius: 40pt,
+ fill: self.colors.primary,
+ ))
+ place(right + bottom, dx: -65pt + bias2, dy: -12pt, circle(
+ radius: 21pt,
+ fill: self.colors.primary,
+ ))
+ place(right + bottom, dx: r * -3% + bias2, dy: -15%, circle(
+ radius: 13pt,
+ fill: self.colors.primary,
+ ))
+ place(right + bottom, dx: r * -2.5% + bias2, dy: -27%, circle(
+ radius: 8pt,
+ fill: self.colors.primary,
+ ))
+ place(center + horizon, dx: bias1, polygon(
+ fill: self.colors.primary-lightest,
+ (35% * page-width, -17%),
+ (70% * page-width, 10%),
+ (35% * page-width, 30%),
+ (0% * page-width, 10%),
+ ))
+ place(center + horizon, dy: 7%, dx: bias1, ellipse(
+ fill: white,
+ width: r * 45%,
+ height: 120pt,
+ ))
+ place(center + horizon, dy: 5%, dx: bias1, ellipse(
+ fill: self.colors.primary-lightest,
+ width: r * 40%,
+ height: 80pt,
+ ))
+ place(center + horizon, dy: 12%, dx: bias1, rect(
+ fill: self.colors.primary-lightest,
+ width: r * 40%,
+ height: 60pt,
+ ))
+ place(center + horizon, dy: 20%, dx: bias1, ellipse(
+ fill: white,
+ width: r * 40%,
+ height: 70pt,
+ ))
+ place(center + horizon, dx: r * 28% + bias1, dy: -6%, circle(
+ radius: 13pt,
+ fill: white,
+ ))
+ },
+ ),
+ ..args,
+ )
+
+ body
+}
--- /dev/null
+#import "../src/exports.typ": *
+
+/// Touying slide function.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - repeat (int, auto): The number of subslides. Default is `auto`, which means touying will automatically calculate the number of subslides.
+///
+/// The `repeat` argument is necessary when you use `#slide(repeat: 3, self => [ .. ])` style code to create a slide. The callback-style `uncover` and `only` cannot be detected by touying automatically.
+///
+/// - setting (function): The setting of the slide. You can use it to add some set/show rules for the slide.
+///
+/// - composer (function): The composer of the slide. You can use it to set the layout of the slide.
+///
+/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` to split the slide into three parts. The first and the last parts will take 1/4 of the slide, and the second part will take 1/2 of the slide.
+///
+/// If you pass a non-function value like `(1fr, 2fr, 1fr)`, it will be assumed to be the first argument of the `cols` function.
+///
+/// The `cols` function is a simple wrapper of the `grid` function. It means you can use the `grid.cell(colspan: 2, ..)` to make the cell take 2 columns.
+///
+/// For example, `#slide(composer: 2)[A][B][#grid.cell(colspan: 2)[Footer]]` will make the `Footer` cell take 2 columns.
+///
+/// If you want to customize the composer, you can pass a function to the `composer` argument. The function should receive the contents of the slide and return the content of the slide, like `#slide(composer: grid.with(columns: 2))[A][B]`.
+///
+/// - bodies (content): The contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide.
+#let slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: setting,
+ composer: composer,
+ ..bodies,
+ )
+})
+
+
+/// Touying metropolis theme.
+///
+/// Example:
+///
+/// ```typst
+/// #show: default-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))
+/// ```
+///
+/// - aspect-ratio (string): The aspect ratio of the slides. Default is `16-9`.
+#let default-theme(
+ aspect-ratio: "16-9",
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(..utils.page-args-from-aspect-ratio(aspect-ratio)),
+ config-common(slide-fn: slide),
+ ..args,
+ )
+
+ body
+}
--- /dev/null
+// This theme is inspired by https://github.com/zbowang/BeamerTheme
+// The typst version was written by https://github.com/OrangeX4
+
+#import "../src/exports.typ": *
+
+#let dewdrop-header(self) = {
+ if self.store.navigation == "sidebar" {
+ place(
+ right + top,
+ {
+ v(4em)
+ show: block.with(width: self.store.sidebar.width, inset: (x: 1em))
+ set align(left)
+ set par(justify: false)
+ set text(size: .9em)
+ components.custom-progressive-outline(
+ self: self,
+ level: auto,
+ alpha: self.store.alpha,
+ text-style: (
+ (fill: self.colors.primary, size: 1em),
+ (fill: self.colors.neutral-darkest, size: .9em),
+ ),
+ vspace: (-.2em,),
+ indent: (0em, self.store.sidebar.at("indent", default: .5em)),
+ fill: (
+ self.store.sidebar.at("fill", default: std.repeat[.]),
+ ),
+ filled: (self.store.sidebar.at("filled", default: false),),
+ paged: (self.store.sidebar.at("paged", default: false),),
+ short-heading: self.store.sidebar.at("short-heading", default: true),
+ )
+ },
+ )
+ } else if self.store.navigation == "mini-slides" {
+ components.mini-slides(
+ self: self,
+ fill: self.colors.primary,
+ alpha: self.store.alpha,
+ display-section: self.store.mini-slides.at(
+ "display-section",
+ default: false,
+ ),
+ display-subsection: self.store.mini-slides.at(
+ "display-subsection",
+ default: true,
+ ),
+ linebreaks: self.store.mini-slides.at("linebreaks", default: true),
+ short-heading: self.store.mini-slides.at("short-heading", default: true),
+ inline: self.store.mini-slides.at("inline", default: false),
+ )
+ }
+}
+
+#let dewdrop-footer(self) = {
+ set align(bottom)
+ set text(size: 0.8em)
+ show: pad.with(.5em)
+ components.left-and-right(
+ text(fill: self.colors.neutral-darkest.lighten(40%), utils.call-or-display(
+ self,
+ self.store.footer,
+ )),
+ text(fill: self.colors.neutral-darkest.lighten(20%), utils.call-or-display(
+ self,
+ self.store.footer-right,
+ )),
+ )
+}
+
+/// Default slide function for the presentation.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - repeat (int, auto): The number of subslides. Default is `auto`, which means touying will automatically calculate the number of subslides.
+///
+/// The `repeat` argument is necessary when you use `#slide(repeat: 3, self => [ .. ])` style code to create a slide. The callback-style `uncover` and `only` cannot be detected by touying automatically.
+///
+/// - setting (function): The setting of the slide. You can use it to add some set/show rules for the slide.
+///
+/// - composer (function, array): The composer of the slide. You can use it to set the layout of the slide.
+///
+/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` to split the slide into three parts. The first and the last parts will take 1/4 of the slide, and the second part will take 1/2 of the slide.
+///
+/// If you pass a non-function value like `(1fr, 2fr, 1fr)`, it will be assumed to be the first argument of the `cols` function.
+///
+/// The `cols` function is a simple wrapper of the `grid` function. It means you can use the `grid.cell(colspan: 2, ..)` to make the cell take 2 columns.
+///
+/// For example, `#slide(composer: 2)[A][B][#grid.cell(colspan: 2)[Footer]]` will make the `Footer` cell take 2 columns.
+///
+/// If you want to customize the composer, you can pass a function to the `composer` argument. The function should receive the contents of the slide and return the content of the slide, like `#slide(composer: grid.with(columns: 2))[A][B]`.
+///
+/// - bodies (array): The contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide.
+#let slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ let self = utils.merge-dicts(
+ self,
+ config-page(
+ header: dewdrop-header,
+ footer: dewdrop-footer,
+ ),
+ config-common(subslide-preamble: self.store.subslide-preamble),
+ )
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: setting,
+ composer: composer,
+ ..bodies,
+ )
+})
+
+
+/// Title slide for the presentation. You should update the information in the `config-info` function. You can also pass the information directly to the `title-slide` function.
+///
+/// Example:
+///
+/// ```typst
+/// #show: dewdrop-theme.with(
+/// config-info(
+/// title: [Title],
+/// logo: emoji.city,
+/// ),
+/// )
+///
+/// #title-slide(subtitle: [Subtitle], extra: [Extra information])
+/// ```
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - extra (string, none): The extra information you want to display on the title slide.
+#let title-slide(
+ config: (:),
+ extra: none,
+ ..args,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(margin: 0em),
+ config,
+ )
+ let info = self.info + args.named()
+ let body = {
+ set text(fill: self.colors.neutral-darkest)
+ set align(center + horizon)
+ block(
+ width: 100%,
+ inset: 3em,
+ {
+ block(
+ fill: self.colors.neutral-light,
+ inset: 1em,
+ width: 100%,
+ radius: 0.2em,
+ text(size: 1.3em, fill: self.colors.primary, text(
+ weight: "medium",
+ info.title,
+ ))
+ + (
+ if info.subtitle != none {
+ linebreak()
+ text(size: 0.9em, fill: self.colors.primary, info.subtitle)
+ }
+ ),
+ )
+ set text(size: .8em)
+ if info.author != none {
+ block(spacing: 1em, info.author)
+ }
+ v(1em)
+ if info.date != none {
+ block(spacing: 1em, utils.display-info-date(self))
+ }
+ set text(size: .8em)
+ if info.institution != none {
+ block(spacing: 1em, info.institution)
+ }
+ if info.contact != none {
+ block(spacing: 1em, info.contact)
+ }
+ if extra != none {
+ block(spacing: 1em, extra)
+ }
+ },
+ )
+ }
+ touying-slide(self: self, body)
+})
+
+
+/// Outline slide for the presentation.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - title (string): The title of the slide. Default is `utils.i18n-outline-title`.
+#let outline-slide(
+ config: (:),
+ title: utils.i18n-outline-title,
+ ..args,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ footer: dewdrop-footer,
+ ),
+ )
+ touying-slide(
+ self: self,
+ config: config,
+ components.adaptive-columns(
+ start: text(
+ 1.2em,
+ fill: self.colors.primary,
+ weight: "bold",
+ utils.call-or-display(self, title),
+ ),
+ text(
+ fill: self.colors.neutral-darkest,
+ outline(title: none, indent: 1em, depth: self.slide-level, ..args),
+ ),
+ ),
+ )
+})
+
+
+/// New section slide for the presentation. You can update it by updating the `new-section-slide-fn` argument for `config-common` function.
+///
+/// Example: `config-common(new-section-slide-fn: new-section-slide.with(numbered: false))`
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - title (string): The title of the slide. Default is `utils.i18n-outline-title`.
+///
+/// - body (array): The contents of the slide.
+#let new-section-slide(
+ config: (:),
+ title: utils.i18n-outline-title,
+ ..args,
+ body,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-page(
+ footer: dewdrop-footer,
+ ),
+ )
+ touying-slide(
+ self: self,
+ config: config,
+ {
+ components.adaptive-columns(
+ start: text(
+ 1.2em,
+ fill: self.colors.primary,
+ weight: "bold",
+ utils.call-or-display(self, title),
+ ),
+ text(
+ fill: self.colors.neutral-darkest,
+ components.progressive-outline(
+ alpha: self.store.alpha,
+ title: none,
+ indent: 1em,
+ depth: self.slide-level,
+ ..args,
+ ),
+ ),
+ )
+ body
+ },
+ )
+})
+
+
+/// Focus on some content.
+///
+/// Example: `#focus-slide[Wake up!]`
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+#let focus-slide(config: (:), body) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(fill: self.colors.primary, margin: 2em),
+ )
+ set text(fill: self.colors.neutral-lightest, size: 1.5em)
+ touying-slide(self: self, config: config, align(horizon + center, body))
+})
+
+
+/// Touying dewdrop theme.
+///
+/// Example:
+///
+/// ```typst
+/// #show: dewdrop-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))
+/// ```
+///
+/// The default colors:
+///
+/// ```typ
+/// config-colors(
+/// neutral-darkest: rgb("#000000"),
+/// neutral-dark: rgb("#202020"),
+/// neutral-light: rgb("#f3f3f3"),
+/// neutral-lightest: rgb("#ffffff"),
+/// primary: rgb("#0c4842"),
+/// )
+/// ```
+///
+/// - aspect-ratio (string): The aspect ratio of the slides. Default is `16-9`.
+///
+/// - navigation (string): The navigation of the slides. You can choose from `"sidebar"`, `"mini-slides"`, and `none`. Default is `"sidebar"`.
+///
+/// - sidebar (dictionary): The configuration of the sidebar. You can set the width, filled, numbered, indent, and short-heading of the sidebar. Default is `(width: 10em, filled: false, numbered: false, indent: .5em, short-heading: true)`.
+/// - width (string): The width of the sidebar.
+/// - filled (boolean): Whether the outline in the sidebar is filled.
+/// - numbered (boolean): Whether the outline in the sidebar is numbered.
+/// - indent (length): The indent of the outline in the sidebar.
+/// - short-heading (boolean): Whether the outline in the sidebar is short.
+///
+/// - mini-slides (dictionary): The configuration of the mini-slides. You can set the height, x, display-section, display-subsection, and short-heading of the mini-slides. Default is `(height: 4em, x: 2em, display-section: false, display-subsection: true, linebreaks: true, short-heading: true)`.
+/// - height (length): The height of the mini-slides.
+/// - x (length): The x position of the mini-slides.
+/// - display-section (boolean): Whether the slides of sections are displayed in the mini-slides.
+/// - display-subsection (boolean): Whether the slides of subsections are displayed in the mini-slides.
+/// - linebreaks (boolean): Whether line breaks are in between links for sections and subsections in the mini-slides.
+/// - short-heading (boolean): Whether the mini-slides are short. Default is `true`.
+/// - inline (boolean): Whether the mini-slides are displayed right after the section or subsection link, or on a new line. Default is `false`.
+///
+/// - footer (content, function): The footer of the slides. Default is `none`.
+///
+/// - footer-right (content, function): The right part of the footer. Default is `context utils.slide-counter.display() + " / " + utils.last-slide-number`.
+///
+/// - primary (color): The primary color of the slides. Default is `rgb("#0c4842")`.
+///
+/// - alpha (fraction, float): The alpha of transparency. Default is `60%`.
+///
+/// - outline-title (content, function): The title of the outline. Default is `utils.i18n-outline-title`.
+///
+/// - subslide-preamble (content, function): The preamble of the subslide. Default is `self => block(text(1.2em, weight: "bold", fill: self.colors.primary, utils.display-current-heading(depth: self.slide-level)))`.
+#let dewdrop-theme(
+ aspect-ratio: "16-9",
+ navigation: "sidebar",
+ sidebar: (
+ width: 10em,
+ filled: false,
+ numbered: false,
+ indent: .5em,
+ short-heading: true,
+ ),
+ mini-slides: (
+ height: 4em,
+ x: 2em,
+ display-section: false,
+ display-subsection: true,
+ linebreaks: true,
+ short-heading: true,
+ ),
+ footer: none,
+ footer-right: context utils.slide-counter.display()
+ + " / "
+ + utils.last-slide-number,
+ primary: rgb("#0c4842"),
+ alpha: 60%,
+ subslide-preamble: self => block(
+ text(
+ 1.2em,
+ weight: "bold",
+ fill: self.colors.primary,
+ utils.display-current-heading(depth: self.slide-level, style: auto),
+ ),
+ ),
+ ..args,
+ body,
+) = {
+ sidebar = utils.merge-dicts(
+ (
+ width: 10em,
+ filled: false,
+ numbered: false,
+ indent: .5em,
+ short-heading: true,
+ ),
+ sidebar,
+ )
+ mini-slides = utils.merge-dicts(
+ (
+ height: 4em,
+ x: 2em,
+ display-section: false,
+ display-subsection: true,
+ linebreaks: true,
+ short-heading: true,
+ ),
+ mini-slides,
+ )
+ set text(size: 20pt)
+ set par(justify: true)
+
+ show: touying-slides.with(
+ config-page(
+ ..utils.page-args-from-aspect-ratio(aspect-ratio),
+ header-ascent: 0em,
+ footer-descent: 0em,
+ margin: if navigation == "sidebar" {
+ (top: 2em, bottom: 1em, x: sidebar.width)
+ } else if navigation == "mini-slides" {
+ (top: mini-slides.height, bottom: 2em, x: mini-slides.x)
+ } else {
+ (top: 2em, bottom: 2em, x: mini-slides.x)
+ },
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ ),
+ config-methods(
+ init: (self: none, body) => {
+ show heading: set text(self.colors.primary)
+
+ body
+ },
+ alert: utils.alert-with-primary-color,
+ ),
+ config-colors(
+ neutral-darkest: rgb("#000000"),
+ neutral-dark: rgb("#202020"),
+ neutral-light: rgb("#f3f3f3"),
+ neutral-lightest: rgb("#ffffff"),
+ primary: primary,
+ ),
+ // save the variables for later use
+ config-store(
+ navigation: navigation,
+ sidebar: sidebar,
+ mini-slides: mini-slides,
+ footer: footer,
+ footer-right: footer-right,
+ alpha: alpha,
+ subslide-preamble: subslide-preamble,
+ ),
+ ..args,
+ )
+
+ body
+}
--- /dev/null
+// One-off Touying theme based on the provided simple theme snippet.
+//
+// - Background: black
+// - Text: white
+// - Title + section titles: rgb(255, 100, 0)
+// - Logo placed in the top-right corner (can be large)
+
+#import "../src/exports.typ": *
+
+#let ACCENT = rgb(255, 100, 0)
+#let BG = rgb("#000000")
+#let FG = rgb("#ffffff")
+
+// --- Slide wrappers (mostly copied from your theme) ---
+
+#let slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ let deco-format(it) = text(size: .6em, fill: self.colors.neutral-light, it)
+
+ let header(self) = deco-format(
+ components.left-and-right(
+ utils.call-or-display(self, self.store.header),
+ utils.call-or-display(self, self.store.header-right),
+ ),
+ )
+
+ let footer(self) = {
+ v(.5em)
+ deco-format(
+ components.left-and-right(
+ utils.call-or-display(self, self.store.footer),
+ utils.call-or-display(self, self.store.footer-right),
+ ),
+ )
+ }
+
+ // Force black background + white text on all slides.
+ let self = utils.merge-dicts(
+ self,
+ config-page(
+ fill: BG,
+ header: header,
+ footer: footer,
+ ),
+ config-common(subslide-preamble: self.store.subslide-preamble),
+ )
+
+ // Ensure default text color is white.
+ set text(fill: FG)
+
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: setting,
+ composer: composer,
+ ..bodies,
+ )
+})
+
+#let centered-slide(config: (:), ..args) = touying-slide-wrapper(self => {
+ set text(fill: FG)
+ touying-slide(self: self, ..args.named(), config: config, align(
+ center + horizon,
+ args.pos().sum(default: none),
+ ))
+})
+
+// Title slide: accent color
+#let title-slide(config: (:), body) = touying-slide-wrapper(self => {
+ set text(fill: ACCENT, size: 1.8em, weight: "bold")
+ touying-slide(
+ self: utils.merge-dicts(self, config-common(freeze-slide-counter: true), config),
+ align(center + horizon, body),
+ )
+})
+
+// Section slide: accent color + uses current level-1 heading
+#let new-section-slide(config: (:), body) = centered-slide(config: config, [
+ #set text(fill: ACCENT)
+ #text(1.2em, weight: "bold", utils.display-current-heading(level: 1))
+
+ #set text(fill: FG)
+ #body
+])
+
+// --- THEME ---
+
+#let ironclaw-theme(
+ aspect-ratio: "16-9",
+
+ // Put whatever logo you want here. Example:
+ // logo: image("assets/logo.png"),
+ logo: [LOGO HERE],
+
+ // How big the logo can be in the top-right corner.
+ // (This is intentionally "one use case" simple.)
+ logo-width: 4cm,
+ logo-height: 2cm,
+
+ body,
+) = {
+ // Place a (potentially large) logo anchored to the page's top-right corner.
+ // `place` overlays without affecting layout, which is what we want for a corner-filling mark.
+ let header-right(self) = {
+ place(
+ top + right,
+ // Use a sized box so images can scale up to fill this region.
+ box(
+ width: logo-width,
+ height: logo-height,
+ inset: 0pt,
+ // If `logo` is an image(...), it will scale according to its own params.
+ // You can pass e.g. image("...", width: logo-width) at callsite too.
+ logo,
+ ),
+ )
+ }
+
+ show: touying-slides.with(
+ config-page(
+ ..utils.page-args-from-aspect-ratio(aspect-ratio),
+ margin: 2em,
+ footer-descent: 0em,
+ fill: BG,
+ ),
+ config-common(
+ slide-fn: slide,
+ title-slide-fn: title-slide,
+ new-section-slide-fn: new-section-slide,
+ zero-margin-header: false,
+ zero-margin-footer: false,
+ ),
+ config-methods(
+ init: (self: none, body) => {
+ // Base typography
+ set text(size: 25pt, fill: FG)
+ show footnote.entry: set text(size: .6em, fill: FG)
+
+ // Default heading styling: white (section slide overrides to ACCENT)
+ show heading.where(level: 1): set text(1.4em, fill: FG)
+
+ body
+ },
+ alert: utils.alert-with-primary-color,
+ ),
+ config-colors(
+ neutral-light: ACCENT,
+ neutral-lightest: BG,
+ neutral-darkest: FG,
+ primary: ACCENT,
+ ),
+ config-store(
+ header: self => utils.display-current-heading(
+ setting: utils.fit-to-width.with(grow: false, 100%),
+ level: 1,
+ depth: self.slide-level,
+ ),
+ header-right: header-right,
+ footer: none,
+ footer-right: context utils.slide-counter.display()
+ + " / "
+ + utils.last-slide-number,
+ subslide-preamble: block(
+ below: 1.5em,
+ text(1.2em, weight: "bold", fill: FG, utils.display-current-heading(level: 2)),
+ ),
+ ),
+ )
+
+ body
+}
--- /dev/null
+// This theme is inspired by https://github.com/matze/mtheme
+// The origin code was written by https://github.com/Enivex
+
+#import "../src/exports.typ": *
+
+/// Default slide function for the presentation.
+///
+/// - title (string): The title of the slide. Default is `auto`.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - repeat (int, string): The number of subslides. Default is `auto`, which means touying will automatically calculate the number of subslides.
+///
+/// The `repeat` argument is necessary when you use `#slide(repeat: 3, self => [ .. ])` style code to create a slide. The callback-style `uncover` and `only` cannot be detected by touying automatically.
+///
+/// - setting (function): The setting of the slide. You can use it to add some set/show rules for the slide.
+///
+/// - composer (function, array): The composer of the slide. You can use it to set the layout of the slide.
+///
+/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` to split the slide into three parts. The first and the last parts will take 1/4 of the slide, and the second part will take 1/2 of the slide.
+///
+/// If you pass a non-function value like `(1fr, 2fr, 1fr)`, it will be assumed to be the first argument of the `cols` function.
+///
+/// The `cols` function is a simple wrapper of the `grid` function. It means you can use the `grid.cell(colspan: 2, ..)` to make the cell take 2 columns.
+///
+/// For example, `#slide(composer: 2)[A][B][#grid.cell(colspan: 2)[Footer]]` will make the `Footer` cell take 2 columns.
+///
+/// If you want to customize the composer, you can pass a function to the `composer` argument. The function should receive the contents of the slide and return the content of the slide, like `#slide(composer: grid.with(columns: 2))[A][B]`.
+///
+/// - bodies (array): The contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide.
+#let slide(
+ title: auto,
+ align: auto,
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ if align != auto {
+ self.store.align = align
+ }
+ let header(self) = {
+ set std.align(top)
+ show: components.cell.with(fill: self.colors.secondary, inset: 1em)
+ set std.align(horizon)
+ set text(fill: self.colors.neutral-lightest, weight: "medium", size: 1.2em)
+ components.left-and-right(
+ {
+ if title != auto {
+ utils.fit-to-width(grow: false, 100%, title)
+ } else {
+ utils.call-or-display(self, self.store.header)
+ }
+ },
+ utils.call-or-display(self, self.store.header-right),
+ )
+ }
+ let footer(self) = {
+ set std.align(bottom)
+ set text(size: 0.8em)
+ pad(
+ .5em,
+ components.left-and-right(
+ text(
+ fill: self.colors.neutral-darkest.lighten(40%),
+ utils.call-or-display(self, self.store.footer),
+ ),
+ text(fill: self.colors.neutral-darkest, utils.call-or-display(
+ self,
+ self.store.footer-right,
+ )),
+ ),
+ )
+ if self.store.footer-progress {
+ place(bottom, components.progress-bar(
+ height: 2pt,
+ self.colors.primary,
+ self.colors.primary-light,
+ ))
+ }
+ }
+ let self = utils.merge-dicts(
+ self,
+ config-page(
+ fill: self.colors.neutral-lightest,
+ header: header,
+ footer: footer,
+ ),
+ )
+ let new-setting = body => {
+ show: std.align.with(self.store.align)
+ set text(fill: self.colors.neutral-darkest)
+ show: setting
+ body
+ }
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: new-setting,
+ composer: composer,
+ ..bodies,
+ )
+})
+
+
+/// Title slide for the presentation. You should update the information in the `config-info` function. You can also pass the information directly to the `title-slide` function.
+///
+/// Example:
+///
+/// ```typst
+/// #show: metropolis-theme.with(
+/// config-info(
+/// title: [Title],
+/// logo: emoji.city,
+/// ),
+/// )
+///
+/// #title-slide(subtitle: [Subtitle], extra: [Extra information])
+/// ```
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - extra (string, none): The extra information you want to display on the title slide.
+#let title-slide(
+ config: (:),
+ extra: none,
+ ..args,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(fill: self.colors.neutral-lightest),
+ config,
+ )
+ let info = self.info + args.named()
+ let body = {
+ set text(fill: self.colors.neutral-darkest)
+ set std.align(horizon)
+ block(
+ width: 100%,
+ inset: 2em,
+ {
+ components.left-and-right(
+ {
+ text(size: 1.3em, weight: "medium", info.title)
+ if info.subtitle != none {
+ linebreak()
+ text(size: 0.9em, info.subtitle)
+ }
+ },
+ text(2em, utils.call-or-display(self, info.logo)),
+ )
+ line(length: 100%, stroke: .05em + self.colors.primary)
+ set text(size: .8em)
+ if info.author != none {
+ block(spacing: 1em, info.author)
+ }
+ if info.date != none {
+ block(spacing: 1em, utils.display-info-date(self))
+ }
+ set text(size: .8em)
+ if info.institution != none {
+ block(spacing: 1em, info.institution)
+ }
+ if info.contact != none {
+ block(spacing: 1em, info.contact)
+ }
+ if extra != none {
+ block(spacing: 1em, extra)
+ }
+ },
+ )
+ }
+ touying-slide(self: self, body)
+})
+
+
+/// Outline slide for the presentation.
+///
+/// Example:
+///
+/// ```typst
+/// #show: metropolis-theme.with(
+/// config-info(
+/// title: [Title],
+/// logo: emoji.city,
+/// ),
+/// )
+///
+/// #outline-slide(indent: (1em,), depth: 1, title: [Contents])
+/// ```
+///
+/// - config (dictionary): The configuration of the slide.
+/// - level (int | auto): The level of the outline. Default is `auto`, which uses the slide-level configured in the `config-common`.
+/// - title (string): The title of the slide. Default is "Outline".
+/// - spacing (length): The spacing between the outline entries. Default is 2em.
+/// - args (any): The arguments to pass to the custom-progressive-outline, see https://touying-typ.github.io/docs/reference/components/custom-progressive-outline. Some values like numbering have defaults to mimic the style of the metropolis theme.
+#let outline-slide(
+ config: (:),
+ level: auto,
+ title: [Outline],
+ spacing: 2em,
+ ..args,
+) = slide(title: title, config: config, self => {
+ let named-args = args.named()
+ let indent = if not "indent" in named-args.keys() { (1em,) } else {
+ named-args.remove("indent")
+ }
+ if type(indent) != array {
+ indent = (indent,)
+ }
+ let vspace = if not "vspace" in named-args.keys() {
+ (spacing, spacing / 3, spacing / 3, spacing / 3)
+ } else { named-args.remove("vspace") }
+ let numbered = if not "numbered" in named-args.keys() { (true,) } else {
+ named-args.remove("numbered")
+ }
+ let numbering = if not "numbering" in named-args.keys() { ("1.",) } else {
+ named-args.remove("numbering")
+ }
+ components.custom-progressive-outline(
+ title: none,
+ depth: if level != auto { level } else { self.slide-level },
+ level: level,
+ indent: indent,
+ vspace: vspace,
+ numbered: numbered,
+ numbering: numbering,
+ ..args.pos(),
+ ..named-args,
+ )
+})
+
+
+/// New section slide for the presentation. You can update it by updating the `new-section-slide-fn` argument for `config-common` function.
+///
+/// Example: `config-common(new-section-slide-fn: new-section-slide.with(numbered: false))`
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - level (int): The level of the heading.
+///
+/// - numbered (boolean): Indicates whether the heading is numbered.
+///
+/// - body (auto): The body of the section. It will be passed by touying automatically.
+#let new-section-slide(
+ config: (:),
+ level: 1,
+ numbered: true,
+ body,
+) = touying-slide-wrapper(self => {
+ let slide-body = {
+ set std.align(horizon)
+ show: pad.with(20%)
+ set text(size: 1.5em)
+ stack(
+ dir: ttb,
+ spacing: 1em,
+ text(self.colors.neutral-darkest, utils.display-current-heading(
+ level: level,
+ numbered: numbered,
+ style: auto,
+ )),
+ block(
+ height: 2pt,
+ width: 100%,
+ spacing: 0pt,
+ components.progress-bar(
+ height: 2pt,
+ self.colors.primary,
+ self.colors.primary-light,
+ ),
+ ),
+ )
+ text(self.colors.neutral-dark, body)
+ }
+ self = utils.merge-dicts(
+ self,
+ config-page(fill: self.colors.neutral-lightest),
+ )
+ touying-slide(self: self, config: config, slide-body)
+})
+
+
+/// Focus on some content.
+///
+/// Example: `#focus-slide[Wake up!]`
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - align (alignment): The alignment of the content. Default is `horizon + center`.
+#let focus-slide(
+ config: (:),
+ align: horizon + center,
+ body,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(fill: self.colors.neutral-dark, margin: 2em),
+ )
+ set text(fill: self.colors.neutral-lightest, size: 1.5em)
+ touying-slide(self: self, config: config, std.align(align, body))
+})
+
+
+/// Touying metropolis theme.
+///
+/// Example:
+///
+/// ```typst
+/// #show: metropolis-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))
+/// ```
+///
+/// Consider using:
+///
+/// ```typst
+/// #set text(font: "Fira Sans", weight: "light", size: 20pt)
+/// #show math.equation: set text(font: "Fira Math")
+/// #set strong(delta: 100)
+/// #set par(justify: true)
+/// ```
+///
+/// The default colors:
+///
+/// ```typ
+/// config-colors(
+/// primary: rgb("#eb811b"),
+/// primary-light: rgb("#d6c6b7"),
+/// secondary: rgb("#23373b"),
+/// neutral-lightest: rgb("#fafafa"),
+/// neutral-dark: rgb("#23373b"),
+/// neutral-darkest: rgb("#23373b"),
+/// )
+/// ```
+///
+/// - aspect-ratio (string): The aspect ratio of the slides. Default is `16-9`.
+///
+/// - align (alignment): The alignment of the content. Default is `horizon`.
+///
+/// - header (content, function): The header of the slide. Default is `self => utils.display-current-heading(setting: utils.fit-to-width.with(grow: false, 100%), depth: self.slide-level)`.
+///
+/// - header-right (content, function): The right part of the header. Default is `self => self.info.logo`.
+///
+/// - footer (content, function): The footer of the slide. Default is `none`.
+///
+/// - footer-right (content, function): The right part of the footer. Default is `context utils.slide-counter.display() + " / " + utils.last-slide-number`.
+///
+/// - footer-progress (boolean): Whether to show the progress bar in the footer. Default is `true`.
+#let metropolis-theme(
+ aspect-ratio: "16-9",
+ align: horizon,
+ header: self => utils.display-current-heading(
+ setting: utils.fit-to-width.with(grow: false, 100%),
+ depth: self.slide-level,
+ ),
+ header-right: self => self.info.logo,
+ footer: none,
+ footer-right: context utils.slide-counter.display()
+ + " / "
+ + utils.last-slide-number,
+ footer-progress: true,
+ ..args,
+ body,
+) = {
+ set text(size: 20pt)
+
+ show: touying-slides.with(
+ config-page(
+ ..utils.page-args-from-aspect-ratio(aspect-ratio),
+ header-ascent: 30%,
+ footer-descent: 30%,
+ margin: (top: 3em, bottom: 1.5em, x: 2em),
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ ),
+ config-methods(
+ alert: utils.alert-with-primary-color,
+ ),
+ config-colors(
+ primary: rgb("#eb811b"),
+ primary-light: rgb("#d6c6b7"),
+ secondary: rgb("#23373b"),
+ neutral-lightest: rgb("#fafafa"),
+ neutral-dark: rgb("#23373b"),
+ neutral-darkest: rgb("#23373b"),
+ ),
+ // save the variables for later use
+ config-store(
+ align: align,
+ header: header,
+ header-right: header-right,
+ footer: footer,
+ footer-right: footer-right,
+ footer-progress: footer-progress,
+ ),
+ ..args,
+ )
+
+ body
+}
--- /dev/null
+// This theme is from https://github.com/andreasKroepelin/polylux/blob/main/themes/simple.typ
+// Author: Andreas Kröpelin
+
+#import "../src/exports.typ": *
+
+/// Default slide function for the presentation.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - repeat (int, auto): The number of subslides. Default is `auto`, which means touying will automatically calculate the number of subslides.
+///
+//// The `repeat` argument is necessary when you use `#slide(repeat: 3, self => [ .. ])` style code to create a slide. The callback-style `uncover` and `only` cannot be detected by touying automatically.
+///
+/// - setting (function): The setting of the slide. You can use it to add some set/show rules for the slide.
+///
+/// - composer (function): The composer of the slide. You can use it to set the layout of the slide.
+///
+/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` to split the slide into three parts. The first and the last parts will take 1/4 of the slide, and the second part will take 1/2 of the slide.
+///
+/// If you pass a non-function value like `(1fr, 2fr, 1fr)`, it will be assumed to be the first argument of the `cols` function.
+///
+/// The `cols` function is a simple wrapper of the `grid` function. It means you can use the `grid.cell(colspan: 2, ..)` to make the cell take 2 columns.
+///
+/// For example, `#slide(composer: 2)[A][B][#grid.cell(colspan: 2)[Footer]]` will make the `Footer` cell take 2 columns.
+///
+/// If you want to customize the composer, you can pass a function to the `composer` argument. The function should receive the contents of the slide and return the content of the slide, like `#slide(composer: grid.with(columns: 2))[A][B]`.
+///
+/// - bodies (array): The contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide.
+#let slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ let deco-format(it) = text(size: .6em, fill: self.colors.neutral-light, it)
+ let header(self) = deco-format(
+ components.left-and-right(
+ utils.call-or-display(self, self.store.header),
+ utils.call-or-display(self, self.store.header-right),
+ ),
+ )
+ let footer(self) = {
+ v(.5em)
+ deco-format(
+ components.left-and-right(
+ utils.call-or-display(self, self.store.footer),
+ utils.call-or-display(self, self.store.footer-right),
+ ),
+ )
+ }
+ let self = utils.merge-dicts(
+ self,
+ config-page(
+ header: header,
+ footer: footer,
+ ),
+ config-common(subslide-preamble: self.store.subslide-preamble),
+ )
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: setting,
+ composer: composer,
+ ..bodies,
+ )
+})
+
+
+/// Centered slide for the presentation.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+#let centered-slide(config: (:), ..args) = touying-slide-wrapper(self => {
+ touying-slide(self: self, ..args.named(), config: config, align(
+ center + horizon,
+ args.pos().sum(default: none),
+ ))
+})
+
+
+/// Title slide for the presentation.
+///
+/// Example: `#title-slide[Hello, World!]`
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+#let title-slide(config: (:), body) = centered-slide(
+ config: utils.merge-dicts(config-common(freeze-slide-counter: true), config),
+ body,
+)
+
+
+/// New section slide for the presentation. You can update it by updating the `new-section-slide-fn` argument for `config-common` function.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+#let new-section-slide(config: (:), body) = centered-slide(config: config, [
+ #text(1.2em, weight: "bold", utils.display-current-heading(level: 1))
+
+ #body
+])
+
+
+/// Focus on some content.
+///
+/// Example: `#focus-slide[Wake up!]`
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - background (color, auto): The background color of the slide. Default is `auto`, which means the primary color of the slides.
+///
+/// - foreground (color): The foreground color of the slide. Default is `white`.
+#let focus-slide(
+ config: (:),
+ background: auto,
+ foreground: white,
+ body,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(fill: if background == auto {
+ self.colors.primary
+ } else {
+ background
+ }),
+ )
+ set text(fill: foreground, size: 1.5em)
+ touying-slide(self: self, config: config, align(center + horizon, body))
+})
+
+
+/// Touying simple theme.
+///
+/// Example:
+///
+/// ```typst
+/// #show: simple-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))
+/// ```
+///
+/// The default colors:
+///
+/// ```typst
+/// config-colors(
+/// neutral-light: gray,
+/// neutral-lightest: rgb("#ffffff"),
+/// neutral-darkest: rgb("#000000"),
+/// primary: aqua.darken(50%),
+/// )
+/// ```
+///
+/// - aspect-ratio (string): The aspect ratio of the slides. Default is `16-9`.
+///
+/// - header (function): The header of the slides. Default is `self => utils.display-current-heading(setting: utils.fit-to-width.with(grow: false, 100%), depth: self.slide-level)`.
+///
+/// - header-right (content): The right part of the header. Default is `self.info.logo`.
+///
+/// - footer (content): The footer of the slides. Default is `none`.
+///
+/// - footer-right (content): The right part of the footer. Default is `context utils.slide-counter.display() + " / " + utils.last-slide-number`.
+///
+/// - primary (color): The primary color of the slides. Default is `aqua.darken(50%)`.
+///
+/// - subslide-preamble (content): The preamble of the subslides. Default is `block(below: 1.5em, text(1.2em, weight: "bold", utils.display-current-heading(level: 2)))`.
+#let simple-theme(
+ aspect-ratio: "16-9",
+ header: self => utils.display-current-heading(
+ setting: utils.fit-to-width.with(grow: false, 100%),
+ level: 1,
+ depth: self.slide-level,
+ ),
+ header-right: self => self.info.logo,
+ footer: none,
+ footer-right: context utils.slide-counter.display()
+ + " / "
+ + utils.last-slide-number,
+ primary: aqua.darken(50%),
+ subslide-preamble: block(
+ below: 1.5em,
+ text(1.2em, weight: "bold", utils.display-current-heading(level: 2)),
+ ),
+ ..args,
+ body,
+) = {
+ show: touying-slides.with(
+ config-page(
+ ..utils.page-args-from-aspect-ratio(aspect-ratio),
+ margin: 2em,
+ footer-descent: 0em,
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ zero-margin-header: false,
+ zero-margin-footer: false,
+ ),
+ config-methods(
+ init: (self: none, body) => {
+ set text(size: 25pt)
+ show footnote.entry: set text(size: .6em)
+ show heading.where(level: 1): set text(1.4em)
+
+ body
+ },
+ alert: utils.alert-with-primary-color,
+ ),
+ config-colors(
+ neutral-light: gray,
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ primary: primary,
+ ),
+ // save the variables for later use
+ config-store(
+ header: header,
+ header-right: header-right,
+ footer: footer,
+ footer-right: footer-right,
+ subslide-preamble: subslide-preamble,
+ ),
+ ..args,
+ )
+
+ body
+}
--- /dev/null
+// Stargazer theme.
+// Authors: Coekjan, QuadnucYard, OrangeX4
+// Inspired by https://github.com/Coekjan/touying-buaa and https://github.com/QuadnucYard/touying-theme-seu
+
+#import "../src/exports.typ": *
+
+#let _tblock(self: none, title: none, it) = {
+ grid(
+ columns: 1,
+ row-gutter: 0pt,
+ block(
+ fill: self.colors.primary-dark,
+ width: 100%,
+ radius: (top: 6pt),
+ inset: (top: 0.4em, bottom: 0.3em, left: 0.5em, right: 0.5em),
+ text(fill: self.colors.neutral-lightest, weight: "bold", title),
+ ),
+
+ rect(
+ fill: gradient.linear(
+ self.colors.primary-dark,
+ self.colors.primary.lighten(90%),
+ angle: 90deg,
+ ),
+ width: 100%,
+ height: 4pt,
+ ),
+
+ block(
+ fill: self.colors.primary.lighten(90%),
+ width: 100%,
+ radius: (bottom: 6pt),
+ inset: (top: 0.4em, bottom: 0.5em, left: 0.5em, right: 0.5em),
+ it,
+ ),
+ )
+}
+
+
+/// Theorem block for the presentation.
+///
+/// - title (string): The title of the theorem. Default is `none`.
+///
+/// - it (content): The content of the theorem.
+#let tblock(title: none, it) = touying-fn-wrapper(_tblock.with(
+ title: title,
+ it,
+))
+
+
+/// Default slide function for the presentation.
+///
+/// - title (string): The title of the slide. Default is `auto`.
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - repeat (auto): The number of subslides. Default is `auto`, which means touying will automatically calculate the number of subslides.
+///
+/// The `repeat` argument is necessary when you use `#slide(repeat: 3, self => [ .. ])` style code to create a slide. The callback-style `uncover` and `only` cannot be detected by touying automatically.
+///
+/// - setting (dictionary): The setting of the slide. You can use it to add some set/show rules for the slide.
+///
+/// - composer (function): The composer of the slide. You can use it to set the layout of the slide.
+///
+/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` to split the slide into three parts. The first and the last parts will take 1/4 of the slide, and the second part will take 1/2 of the slide.
+///
+/// If you pass a non-function value like `(1fr, 2fr, 1fr)`, it will be assumed to be the first argument of the `cols` function.
+///
+/// The `cols` function is a simple wrapper of the `grid` function. It means you can use the `grid.cell(colspan: 2, ..)` to make the cell take 2 columns.
+///
+/// For example, `#slide(composer: 2)[A][B][#grid.cell(colspan: 2)[Footer]]` will make the `Footer` cell take 2 columns.
+///
+/// If you want to customize the composer, you can pass a function to the `composer` argument. The function should receive the contents of the slide and return the content of the slide, like `#slide(composer: grid.with(columns: 2))[A][B]`.
+///
+/// - bodies (content): The contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide.
+#let slide(
+ title: auto,
+ header: auto,
+ footer: auto,
+ align: auto,
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ if align != auto {
+ self.store.align = align
+ }
+ if title != auto {
+ self.store.title = title
+ }
+ if header != auto {
+ self.store.header = header
+ }
+ if footer != auto {
+ self.store.footer = footer
+ }
+ let new-setting = body => {
+ show: std.align.with(self.store.align)
+ show: setting
+ body
+ }
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: new-setting,
+ composer: composer,
+ ..bodies,
+ )
+})
+
+
+/// Title slide for the presentation. You should update the information in the `config-info` function. You can also pass the information directly to the `title-slide` function.
+///
+/// Example:
+///
+/// ```typst
+/// #show: stargazer-theme.with(
+/// config-info(
+/// title: [Title],
+/// logo: emoji.city,
+/// ),
+/// )
+///
+/// #title-slide(subtitle: [Subtitle], extra: [Extra information])
+/// ```
+///
+/// - config (dictionary): The configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - extra (content, none): The extra information you want to display on the title slide.
+#let title-slide(config: (:), extra: none, ..args) = touying-slide-wrapper(
+ self => {
+ self = utils.merge-dicts(
+ self,
+ config,
+ )
+ self.store.title = none
+ let info = self.info + args.named()
+ info.authors = {
+ let authors = if "authors" in info {
+ info.authors
+ } else {
+ info.author
+ }
+ if type(authors) == array {
+ authors
+ } else {
+ (authors,)
+ }
+ }
+ let body = {
+ show: std.align.with(center + horizon)
+ block(
+ fill: self.colors.primary,
+ inset: 1.5em,
+ radius: 0.5em,
+ breakable: false,
+ {
+ text(
+ size: 1.2em,
+ fill: self.colors.neutral-lightest,
+ weight: "bold",
+ info.title,
+ )
+ if info.subtitle != none {
+ parbreak()
+ text(
+ size: 1.0em,
+ fill: self.colors.neutral-lightest,
+ weight: "bold",
+ info.subtitle,
+ )
+ }
+ },
+ )
+ // authors
+ stack(
+ dir: ttb,
+ spacing: 1em,
+ ..info
+ .authors
+ .chunks(3)
+ .map(author-chunk => {
+ grid(
+ columns: (1fr,) * author-chunk.len(),
+ column-gutter: 1em,
+ ..author-chunk.map(author => text(fill: black, author))
+ )
+ }),
+ )
+ v(0.5em)
+ // institution
+ if info.institution != none {
+ parbreak()
+ text(size: 0.7em, info.institution)
+ }
+ if info.contact != none {
+ parbreak()
+ text(size: 0.7em, info.contact)
+ }
+ // date
+ if info.date != none {
+ parbreak()
+ text(size: 1.0em, utils.display-info-date(self))
+ }
+ if extra != none {
+ parbreak()
+ text(size: 0.8em, extra)
+ }
+ }
+ touying-slide(self: self, body)
+ },
+)
+
+
+
+/// Outline slide for the presentation.
+///
+/// - config (dictionary): is the configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - title (string): is the title of the outline. Default is `utils.i18n-outline-title`.
+///
+/// - level (int, none): is the level of the outline. Default is `none`.
+///
+/// - numbered (boolean): is whether the outline is numbered. Default is `true`.
+#let outline-slide(
+ config: (:),
+ title: utils.i18n-outline-title,
+ numbered: true,
+ level: none,
+ ..args,
+) = touying-slide-wrapper(self => {
+ self.store.title = title
+ touying-slide(
+ self: self,
+ config: config,
+ std.align(
+ self.store.align,
+ components.adaptive-columns(
+ text(
+ fill: self.colors.primary,
+ weight: "bold",
+ components.custom-progressive-outline(
+ level: level,
+ alpha: self.store.alpha,
+ indent: (0em, 1em),
+ vspace: (.4em,),
+ numbered: (numbered,),
+ depth: 1,
+ ..args.named(),
+ ),
+ ),
+ )
+ + args.pos().sum(default: none),
+ ),
+ )
+})
+
+
+/// New section slide for the presentation. You can update it by updating the `new-section-slide-fn` argument for `config-common` function.
+///
+/// Example: `config-common(new-section-slide-fn: new-section-slide.with(numbered: false))`
+///
+/// - config (dictionary): is the configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - title (content, function): is the title of the section. The default is `utils.i18n-outline-title`.
+///
+/// - level (int): is the level of the heading. The default is `1`.
+///
+/// - numbered (boolean): is whether the heading is numbered. The default is `true`.
+///
+/// - body (none): is the body of the section. It will be passed by touying automatically.
+#let new-section-slide(
+ config: (:),
+ title: utils.i18n-outline-title,
+ level: 1,
+ numbered: true,
+ ..args,
+ body,
+) = outline-slide(
+ config: config,
+ title: title,
+ level: level,
+ numbered: numbered,
+ ..args,
+ body,
+)
+
+
+
+/// Focus on some content.
+///
+/// Example: `#focus-slide[Wake up!]`
+///
+/// - config (dictionary): is the configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - align (alignment): is the alignment of the content. The default is `horizon + center`.
+#let focus-slide(
+ config: (:),
+ align: horizon + center,
+ body,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(
+ fill: self.colors.primary,
+ margin: 2em,
+ header: none,
+ footer: none,
+ ),
+ )
+ set text(fill: self.colors.neutral-lightest, weight: "bold", size: 1.5em)
+ touying-slide(self: self, config: config, std.align(align, body))
+})
+
+
+/// End slide for the presentation.
+///
+/// - config (dictionary): is the configuration of the slide. You can use `config-xxx` to set the configuration of the slide. For more several configurations, you can use `utils.merge-dicts` to merge them.
+///
+/// - title (string): is the title of the slide. The default is `none`.
+///
+/// - body (array): is the content of the slide.
+#let ending-slide(config: (:), title: none, body) = touying-slide-wrapper(
+ self => {
+ let content = {
+ set std.align(center + horizon)
+ if title != none {
+ block(
+ fill: self.colors.tertiary,
+ inset: (top: 0.7em, bottom: 0.7em, left: 3em, right: 3em),
+ radius: 0.5em,
+ text(size: 1.5em, fill: self.colors.neutral-lightest, title),
+ )
+ }
+ body
+ }
+ touying-slide(self: self, config: config, content)
+ },
+)
+
+
+/// Touying stargazer theme.
+///
+/// Example:
+///
+/// ```typst
+/// #show: stargazer-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))
+/// ```
+///
+/// Consider using:
+///
+/// ```typst
+/// #set text(font: "Fira Sans", weight: "light", size: 20pt)
+/// #show math.equation: set text(font: "Fira Math")
+/// #set strong(delta: 100)
+/// #set par(justify: true)
+/// ```
+/// The default colors:
+///
+///
+/// ```typst
+/// config-colors(
+/// primary: rgb("#005bac"),
+/// primary-dark: rgb("#004078"),
+/// secondary: rgb("#ffffff"),
+/// tertiary: rgb("#005bac"),
+/// neutral-lightest: rgb("#ffffff"),
+/// neutral-darkest: rgb("#000000"),
+/// )
+/// ```
+///
+/// - aspect-ratio (string): is the aspect ratio of the slides. The default is `16-9`.
+///
+/// - align (alignment): is the alignment of the content. The default is `horizon`.
+///
+/// - alpha (float): the alpha of the covered headings in outlines. The default is `20%`.
+///
+/// - title (content, function): is the title in the header of the slide. The default is `self => utils.display-current-heading(depth: self.slide-level)`.
+///
+/// - header-right (content, function): is the right part of the header. The default is `self => self.info.logo`.
+///
+/// - footer (content, function): is the footer of the slide. The default is `none`.
+///
+/// - footer-right (content, function): is the right part of the footer. The default is `context utils.slide-counter.display() + " / " + utils.last-slide-number`.
+///
+/// - progress-bar (boolean): is whether to show the progress bar in the footer. The default is `true`.
+///
+/// - footer-columns (array): is the columns of the footer. The default is `(25%, 25%, 1fr, 5em)`.
+///
+/// - footer-a (content, function): is the left part of the footer. The default is `self => self.info.author`.
+///
+/// - footer-b (content, function): is the second left part of the footer. The default is `self => utils.display-info-date(self)`.
+///
+/// - footer-c (content, function): is the second right part of the footer. The default is `self => if self.info.short-title == auto { self.info.title } else { self.info.short-title }`.
+///
+/// - footer-d (content, function): is the right part of the footer. The default is `context utils.slide-counter.display() + " / " + utils.last-slide-number`.
+#let stargazer-theme(
+ aspect-ratio: "16-9",
+ align: horizon,
+ alpha: 20%,
+ title: self => utils.display-current-heading(depth: self.slide-level),
+ header-right: self => self.info.logo,
+ progress-bar: true,
+ footer-columns: (25%, 25%, 1fr, 5em),
+ footer-a: self => self.info.author,
+ footer-b: self => utils.display-info-date(self),
+ footer-c: self => if self.info.short-title == auto {
+ self.info.title
+ } else {
+ self.info.short-title
+ },
+ footer-d: context utils.slide-counter.display()
+ + " / "
+ + utils.last-slide-number,
+ ..args,
+ body,
+) = {
+ let header(self) = {
+ set std.align(top)
+ grid(
+ rows: (auto, auto),
+ utils.call-or-display(self, self.store.navigation),
+ utils.call-or-display(self, self.store.header),
+ )
+ }
+ let footer(self) = {
+ set text(size: .5em)
+ set std.align(center + bottom)
+ grid(
+ rows: (auto, auto),
+ utils.call-or-display(self, self.store.footer),
+ if self.store.progress-bar {
+ utils.call-or-display(
+ self,
+ components.progress-bar(
+ height: 2pt,
+ self.colors.primary,
+ self.colors.neutral-lightest,
+ ),
+ )
+ },
+ )
+ }
+
+ show: touying-slides.with(
+ config-page(
+ ..utils.page-args-from-aspect-ratio(aspect-ratio),
+ header: header,
+ footer: footer,
+ header-ascent: 0em,
+ footer-descent: 0em,
+ margin: (top: 4em, bottom: 2em, x: 2.5em),
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ ),
+ config-methods(
+ init: (self: none, body) => {
+ set text(size: 20pt)
+ set list(marker: components.knob-marker(primary: self.colors.primary))
+ show figure.caption: set text(size: 0.6em)
+ show footnote.entry: set text(size: 0.6em)
+ show heading: set text(fill: self.colors.primary)
+ show link: it => if type(it.dest) == str {
+ set text(fill: self.colors.primary)
+ it
+ } else {
+ it
+ }
+ show figure.where(kind: table): set figure.caption(position: top)
+
+ body
+ },
+ alert: utils.alert-with-primary-color,
+ tblock: _tblock,
+ ),
+ config-colors(
+ primary: rgb("#005bac"),
+ primary-dark: rgb("#004078"),
+ secondary: rgb("#ffffff"),
+ tertiary: rgb("#005bac"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ ),
+ // save the variables for later use
+ config-store(
+ align: align,
+ alpha: alpha,
+ title: title,
+ header-right: header-right,
+ progress-bar: progress-bar,
+ footer-columns: footer-columns,
+ footer-a: footer-a,
+ footer-b: footer-b,
+ footer-c: footer-c,
+ footer-d: footer-d,
+ navigation: self => components.simple-navigation(
+ self: self,
+ primary: white,
+ secondary: gray,
+ background: self.colors.neutral-darkest,
+ logo: utils.call-or-display(self, self.store.header-right),
+ ),
+ header: self => if self.store.title != none {
+ block(
+ width: 100%,
+ height: 1.8em,
+ fill: gradient.linear(
+ self.colors.primary,
+ self.colors.neutral-darkest,
+ ),
+ place(
+ left + horizon,
+ text(
+ fill: self.colors.neutral-lightest,
+ weight: "bold",
+ size: 1.3em,
+ utils.call-or-display(self, self.store.title),
+ ),
+ dx: 1.5em,
+ ),
+ )
+ },
+ footer: self => {
+ let cell(fill: none, it) = rect(
+ width: 100%,
+ height: 100%,
+ inset: 1mm,
+ outset: 0mm,
+ fill: fill,
+ stroke: none,
+ std.align(horizon, text(fill: self.colors.neutral-lightest, it)),
+ )
+ grid(
+ columns: self.store.footer-columns,
+ rows: (1.5em, auto),
+ cell(fill: self.colors.neutral-darkest, utils.call-or-display(
+ self,
+ self.store.footer-a,
+ )),
+ cell(fill: self.colors.neutral-darkest, utils.call-or-display(
+ self,
+ self.store.footer-b,
+ )),
+ cell(fill: self.colors.primary, utils.call-or-display(
+ self,
+ self.store.footer-c,
+ )),
+ cell(fill: self.colors.primary, utils.call-or-display(
+ self,
+ self.store.footer-d,
+ )),
+ )
+ },
+ ),
+ ..args,
+ )
+
+ body
+}
--- /dev/null
+#import "default.typ"
+#import "simple.typ"
+#import "metropolis.typ"
+#import "dewdrop.typ"
+#import "university.typ"
+#import "aqua.typ"
+#import "stargazer.typ"
+#import "ironclaw.typ"
--- /dev/null
+// University theme
+
+// Originally contributed by Pol Dellaiera - https://github.com/drupol
+
+#import "../src/exports.typ": *
+
+/// Default slide function for the presentation.
+///
+/// - config (dictionary): is the configuration of the slide. Use `config-xxx` to set individual configurations for the slide. To apply multiple configurations, use `utils.merge-dicts` to combine them.
+///
+/// - repeat (int, auto): is the number of subslides. The default is `auto`, allowing touying to automatically calculate the number of subslides. The `repeat` argument is required when using `#slide(repeat: 3, self => [ .. ])` style code to create a slide, as touying cannot automatically detect callback-style `uncover` and `only`.
+///
+/// - setting (dictionary): is the setting of the slide, which can be used to apply set/show rules for the slide.
+///
+/// - composer (array, function): is the layout composer of the slide, allowing you to define the slide layout.
+///
+/// For example, `#slide(composer: (1fr, 2fr, 1fr))[A][B][C]` to split the slide into three parts. The first and the last parts will take 1/4 of the slide, and the second part will take 1/2 of the slide.
+///
+/// If you pass a non-function value like `(1fr, 2fr, 1fr)`, it will be assumed to be the first argument of the `cols` function.
+///
+/// The `cols` function is a simple wrapper of the `grid` function. It means you can use the `grid.cell(colspan: 2, ..)` to make the cell take 2 columns.
+///
+/// For example, `#slide(composer: 2)[A][B][#grid.cell(colspan: 2)[Footer]]` will make the `Footer` cell take 2 columns.
+///
+/// If you want to customize the composer, you can pass a function to the `composer` argument. The function should receive the contents of the slide and return the content of the slide, like `#slide(composer: grid.with(columns: 2))[A][B]`.
+///
+/// - bodies (arguments): is the contents of the slide. You can call the `slide` function with syntax like `#slide[A][B][C]` to create a slide.
+#let slide(
+ config: (:),
+ repeat: auto,
+ setting: body => body,
+ composer: auto,
+ align: auto,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ if align != auto {
+ self.store.align = align
+ }
+ let header(self) = {
+ set std.align(top)
+ grid(
+ rows: (auto, auto),
+ row-gutter: 3mm,
+ if self.store.progress-bar {
+ components.progress-bar(
+ height: 2pt,
+ self.colors.primary,
+ self.colors.tertiary,
+ )
+ },
+ block(
+ inset: (x: .5em),
+ components.left-and-right(
+ text(
+ fill: self.colors.primary,
+ weight: "bold",
+ size: 1.2em,
+ utils.call-or-display(self, self.store.header),
+ ),
+ text(fill: self.colors.primary.lighten(65%), utils.call-or-display(
+ self,
+ self.store.header-right,
+ )),
+ ),
+ ),
+ )
+ }
+ let footer(self) = {
+ set std.align(center + bottom)
+ set text(size: .4em)
+ {
+ let cell(..args, it) = components.cell(
+ ..args,
+ inset: 1mm,
+ std.align(horizon, text(fill: white, it)),
+ )
+ show: block.with(width: 100%, height: auto)
+ grid(
+ columns: self.store.footer-columns,
+ rows: 1.5em,
+ cell(fill: self.colors.primary, utils.call-or-display(
+ self,
+ self.store.footer-a,
+ )),
+ cell(fill: self.colors.secondary, utils.call-or-display(
+ self,
+ self.store.footer-b,
+ )),
+ cell(fill: self.colors.tertiary, utils.call-or-display(
+ self,
+ self.store.footer-c,
+ )),
+ )
+ }
+ }
+ let self = utils.merge-dicts(
+ self,
+ config-page(
+ header: header,
+ footer: footer,
+ ),
+ )
+ let new-setting = body => {
+ show: std.align.with(self.store.align)
+ show: setting
+ body
+ }
+ touying-slide(
+ self: self,
+ config: config,
+ repeat: repeat,
+ setting: new-setting,
+ composer: composer,
+ ..bodies,
+ )
+})
+
+
+/// Title slide for the presentation. You should update the information in the `config-info` function. You can also pass the information directly to the `title-slide` function.
+///
+/// Example:
+///
+/// ```typst
+/// #show: university-theme.with(
+/// config-info(
+/// title: [Title],
+/// logo: emoji.school,
+/// ),
+/// )
+///
+/// #title-slide(subtitle: [Subtitle])
+/// ```
+///
+/// - config (dictionary): is the configuration of the slide. Use `config-xxx` to set individual configurations for the slide. To apply multiple configurations, use `utils.merge-dicts` to combine them.
+///
+/// - extra (string, none): is the extra information for the slide. This can be passed to the `title-slide` function to display additional information on the title slide.
+#let title-slide(
+ config: (:),
+ extra: none,
+ ..args,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config,
+ )
+ let info = self.info + args.named()
+ info.authors = {
+ let authors = if "authors" in info {
+ info.authors
+ } else {
+ info.author
+ }
+ if type(authors) == array {
+ authors
+ } else {
+ (authors,)
+ }
+ }
+ let body = {
+ if info.logo != none {
+ place(right, text(fill: self.colors.primary, info.logo))
+ }
+ std.align(
+ center + horizon,
+ {
+ block(
+ inset: 0em,
+ breakable: false,
+ {
+ text(size: 2em, fill: self.colors.primary, strong(info.title))
+ if info.subtitle != none {
+ parbreak()
+ text(size: 1.2em, fill: self.colors.primary, info.subtitle)
+ }
+ },
+ )
+ set text(size: .8em)
+ stack(
+ dir: ttb,
+ spacing: 1em,
+ ..info
+ .authors
+ .chunks(3)
+ .map(author-chunk => {
+ grid(
+ columns: (1fr,) * author-chunk.len(),
+ column-gutter: 1em,
+ ..author-chunk.map(author => text(
+ fill: self.colors.neutral-darkest,
+ author,
+ ))
+ )
+ }),
+ )
+ v(1em)
+ if info.institution != none {
+ parbreak()
+ text(size: .9em, info.institution)
+ }
+ if info.contact != none {
+ parbreak()
+ text(size: .9em, info.contact)
+ }
+ if info.date != none {
+ parbreak()
+ text(size: .8em, utils.display-info-date(self))
+ }
+ },
+ )
+ }
+ touying-slide(self: self, body)
+})
+
+
+/// New section slide for the presentation. You can update it by updating the `new-section-slide-fn` argument for `config-common` function.
+///
+/// Example: `config-common(new-section-slide-fn: new-section-slide.with(numbered: false))`
+///
+/// - config (dictionary): is the configuration of the slide. Use `config-xxx` to set individual configurations for the slide. To apply multiple configurations, use `utils.merge-dicts` to combine them.
+///
+/// - level (int, none): is the level of the heading.
+///
+/// - numbered (boolean): is whether the heading is numbered.
+///
+/// - body (auto): is the body of the section. This will be passed automatically by Touying.
+#let new-section-slide(
+ config: (:),
+ level: 1,
+ numbered: true,
+ body,
+) = touying-slide-wrapper(self => {
+ let slide-body = {
+ set std.align(horizon)
+ show: pad.with(20%)
+ set text(size: 1.5em, fill: self.colors.primary, weight: "bold")
+ stack(
+ dir: ttb,
+ spacing: .65em,
+ utils.display-current-heading(level: level, numbered: numbered),
+ block(
+ height: 2pt,
+ width: 100%,
+ spacing: 0pt,
+ components.progress-bar(
+ height: 2pt,
+ self.colors.primary,
+ self.colors.primary-light,
+ ),
+ ),
+ )
+ body
+ }
+ touying-slide(self: self, config: config, slide-body)
+})
+
+
+/// Focus on some content.
+///
+/// Example: `#focus-slide[Wake up!]`
+///
+/// - config (dictionary): is the configuration of the slide. Use `config-xxx` to set individual configurations for the slide. To apply multiple configurations, use `utils.merge-dicts` to combine them.
+///
+/// - background-color (color, none): is the background color of the slide. Default is the primary color.
+///
+/// - background-img (string, none): is the background image of the slide. Default is none.
+#let focus-slide(
+ config: (:),
+ background-color: none,
+ background-img: none,
+ body,
+) = touying-slide-wrapper(self => {
+ let background-color = if (
+ background-img == none and background-color == none
+ ) {
+ rgb(self.colors.primary)
+ } else {
+ background-color
+ }
+ let args = (:)
+ if background-color != none {
+ args.fill = background-color
+ }
+ if background-img != none {
+ args.background = {
+ set image(fit: "stretch", width: 100%, height: 100%)
+ background-img
+ }
+ }
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(margin: 1em, ..args),
+ )
+ set text(fill: self.colors.neutral-lightest, weight: "bold", size: 2em)
+ touying-slide(self: self, std.align(horizon, body))
+})
+
+
+// Create a slide where the provided content blocks are displayed in a grid and coloured in a checkerboard pattern without further decoration. You can configure the grid using the rows and `columns` keyword arguments (both default to none). It is determined in the following way:
+///
+/// - If `columns` is an integer, create that many columns of width `1fr`.
+/// - If `columns` is `none`, create as many columns of width `1fr` as there are content blocks.
+/// - Otherwise assume that `columns` is an array of widths already, use that.
+/// - If `rows` is an integer, create that many rows of height `1fr`.
+/// - If `rows` is `none`, create that many rows of height `1fr` as are needed given the number of co/ -ntent blocks and columns.
+/// - Otherwise assume that `rows` is an array of heights already, use that.
+/// - Check that there are enough rows and columns to fit in all the content blocks.
+///
+/// That means that `#matrix-slide[...][...]` stacks horizontally and `#matrix-slide(columns: 1)[...][...]` stacks vertically.
+///
+/// - config (dictionary): is the configuration of the slide. Use `config-xxx` to set individual configurations for the slide. To apply multiple configurations, use `utils.merge-dicts` to combine them.
+#let matrix-slide(
+ config: (:),
+ columns: none,
+ rows: none,
+ ..bodies,
+) = touying-slide-wrapper(self => {
+ self = utils.merge-dicts(
+ self,
+ config-common(freeze-slide-counter: true),
+ config-page(margin: 0em),
+ )
+ touying-slide(
+ self: self,
+ config: config,
+ composer: components.checkerboard.with(columns: columns, rows: rows),
+ ..bodies,
+ )
+})
+
+
+/// Touying university theme.
+///
+/// Example:
+///
+/// ```typst
+/// #show: university-theme.with(aspect-ratio: "16-9", config-colors(primary: blue))
+/// ```
+///
+/// The default colors:
+///
+/// ```typ
+/// config-colors(
+/// primary: rgb("#04364A"),
+/// secondary: rgb("#176B87"),
+/// tertiary: rgb("#448C95"),
+/// neutral-lightest: rgb("#ffffff"),
+/// neutral-darkest: rgb("#000000"),
+/// )
+/// ```
+///
+/// - aspect-ratio (string): is the aspect ratio of the slides. Default is `16-9`.
+///
+/// - align (alignment): is the alignment of the slides. Default is `top`.
+///
+/// - progress-bar (boolean): is whether to show the progress bar. Default is `true`.
+///
+/// - header (content, function): is the header of the slides. Default is `utils.display-current-heading(level: 2)`.
+///
+/// - header-right (content, function): is the right part of the header. Default is `self.info.logo`.
+///
+/// - footer-columns (tuple): is the columns of the footer. Default is `(25%, 1fr, 25%)`.
+///
+/// - footer-a (content, function): is the left part of the footer. Default is `self.info.author`.
+///
+/// - footer-b (content, function): is the middle part of the footer. Default is `self.info.short-title` or `self.info.title`.
+///
+/// - footer-c (content, function): is the right part of the footer. Default is `self => h(1fr) + utils.display-info-date(self) + h(1fr) + context utils.slide-counter.display() + " / " + utils.last-slide-number + h(1fr)`.
+#let university-theme(
+ aspect-ratio: "16-9",
+ align: top,
+ progress-bar: true,
+ header: utils.display-current-heading(level: 2, style: auto),
+ header-right: self => (
+ box(utils.display-current-heading(level: 1)) + h(.3em) + self.info.logo
+ ),
+ footer-columns: (25%, 1fr, 25%),
+ footer-a: self => self.info.author,
+ footer-b: self => if self.info.short-title == auto {
+ self.info.title
+ } else {
+ self.info.short-title
+ },
+ footer-c: self => {
+ h(1fr)
+ utils.display-info-date(self)
+ h(1fr)
+ context utils.slide-counter.display() + " / " + utils.last-slide-number
+ h(1fr)
+ },
+ ..args,
+ body,
+) = {
+ show: touying-slides.with(
+ config-page(
+ ..utils.page-args-from-aspect-ratio(aspect-ratio),
+ header-ascent: 0em,
+ footer-descent: 0em,
+ margin: (top: 2em, bottom: 1.25em, x: 2em),
+ ),
+ config-common(
+ slide-fn: slide,
+ new-section-slide-fn: new-section-slide,
+ ),
+ config-methods(
+ init: (self: none, body) => {
+ set text(size: 25pt)
+ show heading.where(level: 3): set text(fill: self.colors.primary)
+ show heading.where(level: 4): set text(fill: self.colors.primary)
+
+ body
+ },
+ alert: utils.alert-with-primary-color,
+ ),
+ config-colors(
+ primary: rgb("#04364A"),
+ secondary: rgb("#176B87"),
+ tertiary: rgb("#448C95"),
+ neutral-lightest: rgb("#ffffff"),
+ neutral-darkest: rgb("#000000"),
+ ),
+ // save the variables for later use
+ config-store(
+ align: align,
+ progress-bar: progress-bar,
+ header: header,
+ header-right: header-right,
+ footer-columns: footer-columns,
+ footer-a: footer-a,
+ footer-b: footer-b,
+ footer-c: footer-c,
+ ),
+ ..args,
+ )
+
+ body
+}
--- /dev/null
+[package]
+name = "touying"
+version = "0.7.3"
+entrypoint = "lib.typ"
+authors = ["OrangeX4", "zral0kh", "enklht", "Andreas Kröpelin", "ntjess", "Enivex", "Pol Dellaiera", "pride7", "Coekjan"]
+license = "MIT"
+description = "A powerful package for creating presentation slides in Typst."
+repository = "https://github.com/touying-typ/touying"
+keywords = ["presentation", "pre", "slide", "slides", "lecture", "touying", "beamer", "talk"]
+categories = ["presentation"]
+exclude = ["examples"]
+compiler = "0.12.0"
+
+[tool.tytanic]
+default.ppi = 72
\ No newline at end of file