diff --git a/packages/preview/xwysyy/0.5.0/LICENSE b/packages/preview/xwysyy/0.5.0/LICENSE new file mode 100644 index 0000000000..45bb67b23e --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Carlos-Mero +Copyright (c) 2026 xwysyy + +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. diff --git a/packages/preview/xwysyy/0.5.0/README.md b/packages/preview/xwysyy/0.5.0/README.md new file mode 100644 index 0000000000..d61369a834 --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/README.md @@ -0,0 +1,210 @@ +

xwysyy

+ +

+ Typst Universe + License: MIT + Typst version: >= 0.15.0 + touying version: 0.8.0 + Built-in themes +

+ +

+ 中文 | English +

+ +Academic presentation templates built on [touying](https://github.com/touying-typ/touying). The package covers slide decks, handouts, speaker notes, and pdfpc metadata. The visual theme is derived from [Carlos-Mero/may](https://github.com/Carlos-Mero/may) under MIT. + +## Features + +- Universe template support: `typst init @preview/xwysyy:0.5.0` creates a ready-to-compile deck. +- Six built-in themes: `sky`, `sunset`, `forest`, `midnight`, `violet`, and `graphite`. +- Custom theme dictionaries can be passed directly to `theme`, so users can customize colors without forking the package. +- `xwysyy-pre` takes `font`, `code-font`, and `lang`, plus `heading-font` for the header title. +- Touying handout mode, `#speaker-note`, and pdfpc export are documented with tagged source examples. +- Eight semantic layout components (`duo-slide`, `grid-slide`, `figure-slide`, `stat-slide`, ...) measure every block at compile time, distribute space fill-first, and export layout telemetry without hand-written `#v()` spacing. + +## Preview + +Rendered previews are generated from the [tagged source examples](https://github.com/xwysyy/xwysyy-typst/tree/v0.5.0/examples). + +### Slide Themes + +| sky | sunset | forest | +|:---:|:---:|:---:| +| ![Sky theme cover](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-theme-sky-p1-01.png) | ![Sunset theme cover](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-theme-sunset-p1-01.png) | ![Forest theme cover](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-theme-forest-p1-01.png) | + +| midnight | violet | graphite | +|:---:|:---:|:---:| +| ![Midnight theme cover](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-theme-midnight-p1-01.png) | ![Violet theme cover](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-theme-violet-p1-01.png) | ![Graphite theme cover](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-theme-graphite-p1-01.png) | + +### Component Pages + +| Sky cover | Sky components | +|:---:|:---:| +| ![Sky theme cover slide](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-sky-p1-01.png) | ![Sky theme textbox components](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-sky-p5-05.png) | + +| Sunset cover | Sunset components | +|:---:|:---:| +| ![Sunset theme cover slide](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-sunset-p1-01.png) | ![Sunset theme textbox components](https://raw.githubusercontent.com/xwysyy/xwysyy-typst/v0.5.0/assets/preview-sunset-p5-05.png) | + +## Quick Start + +Create a new project from the Universe template: + +```bash +typst init @preview/xwysyy:0.5.0 my-talk +cd my-talk +typst compile main.typ +``` + +Use the package in an existing Typst project: + +```typst +#import "@preview/xwysyy:0.5.0": * + +#show: xwysyy-pre.with( + theme: "sunset", + config-info( + title: [My Presentation Title], + subtitle: [Subtitle], + author: " ", + date: datetime.today(), + institution: " ", + ), +) + +#title-slide() +#outline-slide() + += Section Title + +== Slide Title + +Body text with *bold* and #red[red highlight]. + +#textbox( + [*Module A* + + First column], + + [*Module B* + + Second column], +) + +#end-slide(title: [Thank You!], body: [Questions?]) +``` + +## Themes + +Select a built-in theme by name: + +```typst +#show: xwysyy-pre.with( + theme: "forest", + config-info(title: [My Presentation]), +) +``` + +Pass a custom dictionary directly: + +```typst +#let my-theme = ( + sea: rgb("#1f5d45"), + sky: rgb("#a8d5ba"), + skyll: rgb("#f5fbf7"), + paper: rgb("#f7faf8"), + page-fill: white, +) + +#show: xwysyy-pre.with( + theme: my-theme, + config-info(title: [My Presentation]), +) +``` + +All five fields are required: + +| Field | Purpose | +|-------|---------| +| `sea` | Primary dark color for the header title, links, table heads, and badges | +| `sky` | Accent color, also the fade-out end of the header rule | +| `skyll` | Code block, table row, and textbox fill | +| `paper` | Text on dark backgrounds | +| `page-fill` | Slide page background | + +## Component Reference + +| Category | API | Usage | +|----------|-----|-------| +| Slide entry | `xwysyy-pre` | `#show: xwysyy-pre.with(theme: "sky", ...)` | +| Title slide | `title-slide` | `#title-slide()` | +| Outline | `outline-slide` | `#outline-slide()` auto-collects section headings | +| Content slide | `xwysyy-slide` | `== Title` auto-triggers | +| Section transition | `new-section-slide` | `= Title` auto-triggers | +| Full-screen image | `image-slide` | `#image-slide(img: image("bg.png"))` | +| End slide | `end-slide` | `#end-slide(title: [...])` | +| Layout · pair | `duo-slide` | figure over text, measured spacing + telemetry | +| Layout · single | `focus-slide` | one centered block for sparse pages | +| Layout · columns | `grid-slide` | N equal-height peer columns | +| Layout · stack | `stack-slide` | N vertical blocks, the visual grows dominant or cards grow tall | +| Layout · compare | `compare-slide` | two top-aligned blocks read as a contrast | +| Layout · stats | `stat-slide` | a row of big-number metric tiles | +| Layout · figure | `figure-slide` | figure, tight caption, optional takeaway | +| Layout · sidebar | `sidebar-slide` | a label tab beside a content card | +| Text box | `textbox` | `#textbox[Content]` or `#textbox([Col 1], [Col 2])` | +| Highlight | `red` / `bred` | `#red[text]` / `#bred[bold red]` | +| Highlight | `yellow` / `byellow` | `#yellow[text]` / `#byellow[bold yellow]` | + +The layout components (`duo-slide`, `focus-slide`, `grid-slide`, `stack-slide`, `compare-slide`, `stat-slide`, `figure-slide`, `sidebar-slide`) take typed content items (`visual` / `card` / `takeaway` / `plain`, with `metric` entries for `stat-slide`; `sidebar-slide` takes plain content) with declared sizing, measure every block, distribute space fill-first, and export `` v4 telemetry (allocated frame, natural preferred size, 2-D payload bbox with a measured/declared source, and paint box + fill per object). For stepwise reveal use the `reveal: true` parameter of the multi-block components (all but `focus-slide` and `sidebar-slide`) instead of `#pause`, which cannot appear inside the components (touying panics). See the [layout guide](https://github.com/xwysyy/xwysyy-typst/blob/v0.5.0/docs/LAYOUT.md). + +## Handouts And Speaker Notes + +Pass touying's handout setting through `xwysyy-pre`. A command-line switch can be wired as follows: + +```typst +#let handout = sys.inputs.at("handout", default: "false") == "true" + +#show: xwysyy-pre.with( + config-common(handout: handout), + config-info(title: [My Presentation]), +) +``` + +```bash +typst compile main.typ slides.pdf +typst compile --input handout=true main.typ slides-handout.pdf +``` + +Speaker notes are available because `xwysyy.typ` re-exports touying: + +```typst +#speaker-note[ + Mention the ablation table before moving to the next section. +] +``` + +Export pdfpc metadata: + +```bash +typst eval --in main.typ --format json 'query().first().value' > slides.pdfpc +``` + +## Requirements + +- Typst >= 0.15.0 +- touying 0.8.0, downloaded on first compile +- physica 0.9.8, downloaded on first compile +- Default local fonts: Times New Roman, Noto Serif CJK SC, Libertinus Sans, Noto Sans CJK SC, Maple Mono, and Noto Sans Mono CJK SC +- Typst web app users can pass web-available fonts with `font:`, `heading-font:`, and `code-font:` + +Full API reference: [docs/USAGE.md](https://github.com/xwysyy/xwysyy-typst/blob/v0.5.0/docs/USAGE.md). Customization guide: [docs/CUSTOMIZATION.md](https://github.com/xwysyy/xwysyy-typst/blob/v0.5.0/docs/CUSTOMIZATION.md). Theme generator: [docs/THEME-GENERATOR.md](https://github.com/xwysyy/xwysyy-typst/blob/v0.5.0/docs/THEME-GENERATOR.md). + +## Acknowledgements + +- Theme derived from [Carlos-Mero/may](https://github.com/Carlos-Mero/may) under MIT +- Built on [touying](https://github.com/touying-typ/touying) + +## License + +[MIT](./LICENSE) diff --git a/packages/preview/xwysyy/0.5.0/src/elements.typ b/packages/preview/xwysyy/0.5.0/src/elements.typ new file mode 100644 index 0000000000..a7e8d5ea7f --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/src/elements.typ @@ -0,0 +1,139 @@ +// Shared show-chain (xwysyy-elements) and reusable components (info, textbox). + +#import "@preview/touying:0.8.0": components +#import "themes.typ": * + +#let xwysyy-elements( + doc, + code-font: ("Maple Mono", "Noto Sans Mono CJK SC"), + t-sea: sea, + t-sky: sky, + t-skyll: skyll, + t-paper: paper, +) = [ + // Bold enhancement (recipe in themes.typ `_bold-run`) + #show strong: it => _bold-run(it.body) + + // List style + #set list(marker: (text(fill: t-sea, [❖]), text(fill: t-sky, [⬦]), text(fill: t-sky, [–])), spacing: 1.2em, indent: 0.5em, body-indent: 0.8em) + #set enum(spacing: 1.2em, indent: 0.5em) + + // Italic — per-character synthetic skew for CJK + #show emph: it => { + if type(it.body) == content and it.body.has("text") { + for c in it.body.text { + box(skew(ax: -8deg, c)) + } + } else { + box(skew(ax: -8deg, it.body)) + } + } + + // No image shadow: wrapping images in a box sized by measure() collapses + // percentage-width images to zero size. + + // Figure captions — smaller + #show figure.caption: it => { + set text(size: 0.78em, fill: luma(100)) + v(0.3em) + it + } + + // Table captions on top + #show figure.where(kind: table): set figure.caption(position: top) + + // Codeblocks + #show raw.where(block: true): it => { + set text(font: code-font, size: 0.9em) + block( + width: 100%, + height: auto, + fill: t-skyll, + inset: 0.6em, + radius: 0.5em, + it + ) + } + // Inline code — the chip fill is painted with `outset` so it does not join + // baseline/line-height layout (the old inset+baseline recipe sank the code + // text ~0.1em below the surrounding baseline). + #show raw.where(block: false): it => { + set text(font: code-font) + box( + fill: t-skyll, + inset: (x: 0.3em), + outset: (y: 0.2em), + radius: 0.3em, + it + ) + } + + // Links + #show link: underline + #show link: it => { + set text(fill: t-sea) + it + } + + // Detail Decoration — longer patterns first to avoid partial matches. + // Each rule is guarded so it never rewrites code content: string show rules + // also match text inside raw, which used to turn `<=` in code into ⇐. + // NB: `text.font` reports lowercased family names — compare case-insensitively. + #let code-head = lower(if type(code-font) == array { code-font.first() } else { code-font }) + #let non-code(arrow) = it => context { + let f = text.font + let head = if type(f) == array and f.len() > 0 { f.first() } else { f } + if lower(head) == code-head { it } else { arrow } + } + #show "<==>": non-code([$arrow.l.r.double.long$]) + #show "<=>": non-code([$<=>$]) + #show "-->": non-code([$-->$]) + #show "<--": non-code([$<--$]) + #show "==>": non-code([$==>$]) + #show "<==": non-code([$arrow.l.double.long$]) + #show "->": non-code([$->$]) + #show "<-": non-code([$<-$]) + #show "=>": non-code([$=>$]) + #show "<=": non-code([$arrow.l.double$]) + #show "|->": non-code([$|->$]) + + // Tables — seamless filled header + zebra body (no gutter: cell gaps used to + // slice the fills into fragments), theme-toned thin hlines + #set table( + stroke: none, + inset: (x: 0.6em, y: 0.42em), + align: center, + fill: (x, y) => if y == 0 { t-sea } else if calc.even(y) { t-skyll } else { none }, + ) + #set table.hline(stroke: 0.5pt + t-sea.lighten(30%)) + #show table.cell: it => { + if it.y == 0 { + set text(t-paper, weight: "bold") + it + } else {it} + } + + #doc +] + +// information item +#let info(something, description) = [ + *#something* #h(1fr) *#description*\ +] + +#let textbox(inset: 0.8em, radius: 0.4em, width: 100%, gutter: 0.6em, ..bodies) = context { + let t = _theme-state.get() + let bodies = bodies.pos() + if bodies.len() == 1 { + block(width: width, fill: t.skyll, inset: inset, radius: radius, bodies.first()) + } else { + components.lazy-layout(grid( + columns: (1fr,) * bodies.len(), + gutter: gutter, + ..bodies.map(b => block(width: 100%, fill: t.skyll, inset: inset, radius: radius, { + b + components.lazy-v(1fr) + })), + )) + } +} diff --git a/packages/preview/xwysyy/0.5.0/src/layout.typ b/packages/preview/xwysyy/0.5.0/src/layout.typ new file mode 100644 index 0000000000..25c323351c --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/src/layout.typ @@ -0,0 +1,1366 @@ +// Semantic layout layer for xwysyy-typst — telemetry schema v4. +// +// Authors (human or AI) pick a semantic component and provide typed content +// items; each component measures the real rendered size of every block, runs +// a shared allocator, and exports the *measured* normalized geometry as +// `` v4 metadata for scripts/slide-check.py. +// +// Honesty rules baked into the schema: +// * sizing is DECLARED, never inferred: percent-sized content must be +// wrapped in `visual(...)` (fit: "stretch"); required slots panic on +// `none`; text slots panic on content with no measurable width or height +// (spacers, empty strings, bare rules) instead of masquerading as a +// payload; +// * every object carries `frame` (allocated), `preferred` (what the +// allocator saw), `payload` (a 2-D flow bbox of the inner content — +// approximate for wrapped text, exact for fixed-size content) and +// `paint` (the visible card box + its fill colour, or none); +// * `payload_source` separates "measured" payloads from "declared" ones +// (stretch slots and percent-width media): a declared payload is a +// claim, not evidence — the checker verifies it against rendered pixels +// instead of trusting it; +// * unbreakable content wider than its slot is exported as `overflow_x` +// (wrapped text is clamped honestly; a long URL that cannot wrap is not); +// * the allocator has four states — normal / compressed (gaps squeezed +// below their preferred size) / tight (outer margins consumed) / +// overflow — with the invariants overflow => body_overflow_ratio > 0 and +// compressed => gap_ratio < 1; +// * one `` mapping is emitted per physically rendered +// subslide, carrying the physical body geometry in pt, and the slide id +// is stable across subslides (touying's logical slide counter), so every +// frame joins its layout record and coverage counts real pages. +// +// Authors never write raw `v()` spacing or absolute coordinates; they adjust +// `mode` and read the numeric feedback the checker returns after each +// compile. Numeric layout knobs live in the `tuning` dictionary (validated +// for key, type, and range); the AI generation contract treats `tuning` as +// off-limits and the telemetry records whether it was used. +// +// Stepwise reveal: touying's `#pause` cannot be used inside these components +// (marks do not survive `context` / `layout` closures; touying panics). +// Components take `reveal: true` (or per-item `reveal-from`) instead, which +// shows blocks one subslide at a time via the callback-style `utils.uncover` +// — hidden steps keep their measured space, so the layout is identical on +// every subslide. One resolver (`_steps`) serves every component and an +// explicit `reveal-from` always wins over the `reveal: true` sugar; +// components without reveal steps have a single frame and reject any +// `reveal-from` beyond step 1 instead of silently ignoring it. + +#import "@preview/touying:0.8.0": utils +#import "slides.typ": xwysyy-slide +#import "themes.typ": _theme-state + +// Capture `top` / `left` before the parameters of the same name (duo's `top`, +// compare's `left`) shadow them inside a component body. +#let _atop = top +#let _aleft = left + +#let _clamp(x, lo, hi) = calc.min(calc.max(x, lo), hi) + +// Optical center sits slightly above the geometric center so a centered group +// does not read as sinking. Only focus-slide centers a group; every other +// component fills the body instead. +#let _OPTICAL-CENTER = 0.46 + +// Fill-first distribution. Outer margins are pinned small; leftover space +// grows the content (figures become dominant, cards become tall) instead of +// becoming blank margin. +#let _MARGIN-TOP = 0.07 +#let _MARGIN-BOT = 0.09 +#let _FILL = 1.0 - _MARGIN-TOP - _MARGIN-BOT +// A card row occupies at least this fraction of the body height, so a row of +// short cards reads as substantial cards rather than tiles floating in space. +#let _CARD-FILL-MIN = 0.60 +// Hard minimum for a stretch visual: below this the visual is starved and the +// slide must degrade to tight/overflow instead of reporting normal. +#let _VISUAL-MIN = 0.28 +// Semantic gaps compress down to this fraction of their preferred size before +// the slide starts consuming its outer margins. +#let _GAP-MIN = 0.4 +// Card inner padding. +#let _CARD-PAD = 0.9em +// stat-slide shrinks a long value to fit its tile, but never below this scale +// (below it the value wraps and the tile grows, reported honestly by fit). +#let _STAT-SCALE-MIN = 0.6 +// Measurement epsilon: below this a dimension counts as zero. +#let _EPS-L = 0.01pt + +#let _MODES = ("compact", "balanced", "separated") +#let _check-mode(mode) = { + if mode not in _MODES { + panic("unknown mode " + repr(mode) + "; use \"compact\" | \"balanced\" | \"separated\"") + } +} + +// Gap between semantically-related blocks, as a fraction of body height. The +// three values land inside the checker's proximity ranges so a correctly +// generated slide passes without diagnostics. +#let _mode-gap(mode) = { + if mode == "compact" { 0.06 } else if mode == "separated" { 0.22 } else { 0.13 } +} +#let _mode-proximity(mode) = { + if mode == "compact" { "compact" } else if mode == "separated" { "loose" } else { "medium" } +} + +// Numeric layout knobs, validated for key, type, and range so a typo or a +// nonsense value fails loudly instead of being silently ignored. +// `spec` maps key -> (default:, lo:, hi:). +#let _tuning(comp, given, spec) = { + for (k, v) in given { + if k not in spec { + panic(comp + ": unknown tuning key " + repr(k) + "; allowed: " + repr(spec.keys())) + } + if type(v) != float and type(v) != int { + panic(comp + ": tuning " + repr(k) + " must be a number, got " + repr(v)) + } + let s = spec.at(k) + if v < s.lo or v > s.hi { + panic(comp + ": tuning " + repr(k) + " = " + repr(v) + " outside [" + repr(s.lo) + ", " + repr(s.hi) + "]") + } + } + let out = (:) + for (k, s) in spec { out.insert(k, float(given.at(k, default: s.default))) } + out +} + +#let _require(comp, slot, v) = { + if v == none { panic(comp + ": " + slot + " is required") } +} + +// Reveal gating. The block becomes visible from subslide `step` (1-based); +// hidden steps keep their measured space. Uses the callback-style +// `utils.uncover` because mark-based `#pause` / global `#uncover` panic inside +// `context` / `layout`. +#let _from(self, step, body) = { + if step <= 1 { body } else { utils.uncover(self: self, str(step) + "-", body) } +} + +// --------------------------------------------------------------------------- +// typed items (declared sizing; sidebar slots take plain content instead) +// --------------------------------------------------------------------------- +// +// `visual(...)` marks the slide's visual payload: never carded, and with +// fit: "stretch" (the default) it absorbs free space and its content is told +// to fill the allocated frame (use `rect(height: 100%)`, or +// `image(width: 100%, height: 100%, fit: "contain")` for real images). +// fit: "natural" keeps the visual at its measured size (`image(width: 100%)`). +// `card(...)` / `takeaway(...)` draw the theme card behind their content. +// `plain(...)` is an uncarded text block. Plain (untyped) content passed to +// a slot is coerced per component (documented at each component). +// +// Roles form a closed set: they weight the checker's optical-centre estimate +// and must not be able to change its control flow, so there is no +// "decorative" escape hatch. + +#let _ROLES = ( + "main_visual", "figure", "image", "chart", "visual", "table", "formula", + "callout", "takeaway", "option", "column", "explanation", "label", + "metric", "text", "content", "caption", +) +#let _check-role(ctor, role) = { + if role not in _ROLES { + panic(ctor + ": role " + repr(role) + " is not in the closed role set " + repr(_ROLES)) + } +} + +#let visual(body, fit: "stretch", role: "main_visual", reveal-from: auto) = { + if fit not in ("stretch", "natural") { + panic("visual: fit must be \"stretch\" or \"natural\", got " + repr(fit)) + } + _check-role("visual", role) + (xwysyy-item: true, kind: "visual", fit: fit, role: role, body: body, reveal-from: reveal-from) +} +#let card(body, role: "explanation", reveal-from: auto) = { + _check-role("card", role) + (xwysyy-item: true, kind: "card", fit: "natural", role: role, body: body, reveal-from: reveal-from) +} +#let takeaway(body, reveal-from: auto) = ( + xwysyy-item: true, kind: "takeaway", fit: "natural", role: "takeaway", body: body, reveal-from: reveal-from, +) +#let plain(body, role: "text", reveal-from: auto) = { + _check-role("plain", role) + (xwysyy-item: true, kind: "plain", fit: "natural", role: role, body: body, reveal-from: reveal-from) +} + +#let _as-item(x, default-kind) = { + if type(x) == dictionary and x.at("xwysyy-item", default: false) { x } + else if default-kind == "visual" { + (xwysyy-item: true, kind: "visual", fit: "natural", role: "main_visual", body: x, reveal-from: auto) + } else if default-kind == "takeaway" { + (xwysyy-item: true, kind: "takeaway", fit: "natural", role: "takeaway", body: x, reveal-from: auto) + } else { + (xwysyy-item: true, kind: "card", fit: "natural", role: "explanation", body: x, reveal-from: auto) + } +} + +#let _painted(it) = it.kind in ("card", "takeaway") +#let _stretchy(it) = it.kind == "visual" and it.fit == "stretch" + +#let _KINDS = ("visual", "card", "takeaway", "plain") + +// Central item validation, run up front in every component. The constructor +// marker is not trusted: a hand-built dictionary with an unknown kind, fit, or +// role fails here instead of silently taking some other code path. +// `stretch: false` marks slots sized by their natural height; `reveal: false` +// marks components without reveal steps, which accept `reveal-from: 1` (their +// single frame) and reject any later step instead of ignoring it. +#let _validate-item(comp, slot, it, stretch: true, reveal: true) = { + if it.at("kind", default: none) not in _KINDS { + panic(comp + ": " + slot + " item kind must be one of " + repr(_KINDS) + + ", got " + repr(it.at("kind", default: none))) + } + if it.at("fit", default: none) not in ("stretch", "natural") { + panic(comp + ": " + slot + " fit must be \"stretch\" or \"natural\", got " + + repr(it.at("fit", default: none))) + } + if it.at("role", default: none) not in _ROLES { + panic(comp + ": " + slot + " role " + repr(it.at("role", default: none)) + + " is not in the closed role set " + repr(_ROLES)) + } + if it.at("body", default: none) == none { + panic(comp + ": " + slot + " body is required") + } + if not stretch and _stretchy(it) { + panic(comp + ": " + slot + " cannot be visual(fit: \"stretch\") — this slot is " + + "sized by its natural height; use fit: \"natural\"") + } + let rf = it.at("reveal-from", default: auto) + if not reveal and rf != auto and rf != 1 { + panic(comp + ": " + slot + " has no reveal steps; remove reveal-from") + } + it +} + +// Relation kinds an author may declare between blocks. Structural kinds +// (peer / caption / labels) are emitted by the components themselves. +#let _AUTHOR-RELS = ("supports", "contrast") +#let _check-relation(comp, rel) = { + if rel not in _AUTHOR-RELS { + panic(comp + ": relation must be one of " + repr(_AUTHOR-RELS) + ", got " + repr(rel)) + } +} + +// Resolve per-item reveal steps — the single resolver for every component. +// `reveal: true` reveals auto items in order (item i on subslide +// min(i+1, max-step)); an explicit `reveal-from` must be an integer in +// [1, max-step] and ALWAYS wins over the sugar. +#let _steps(comp, items, reveal, max-step) = { + items.enumerate().map(((i, it)) => { + let rf = it.at("reveal-from", default: auto) + if rf == auto { + if reveal { calc.min(i + 1, max-step) } else { 1 } + } else { + if type(rf) != int { + panic(comp + ": reveal-from must be an integer >= 1, got " + repr(rf)) + } + if rf < 1 or rf > max-step { + panic(comp + ": reveal-from = " + str(rf) + " outside [1, " + str(max-step) + "]") + } + rf + } + }) +} + +// --------------------------------------------------------------------------- +// allocator +// --------------------------------------------------------------------------- +// +// Column: every item is a spec (min:, pref:, max:, grow:) in absolute +// lengths (max: none = unbounded); every gap is (min:, pref:). Resolution +// order and states: +// normal — everything at preferred fits the safe area; free space goes +// to grow-weighted items (water-filling against max); residual +// free space centers the group inside the safe area +// compressed — fits the safe area only with gaps squeezed below preferred +// (but not below their min); gap_ratio in [_GAP-MIN, 1) +// tight — fits the page only by consuming the outer margins; gaps at +// min, group centered on the page; margin_deficit_ratio > 0 +// overflow — taller than the page even at minimums; +// body_overflow_ratio > 0 (hard invariant) +// `gap_ratio` is the REAL ratio of the summed actual gaps to the summed +// preferred gaps (1.0 when the layout has no gaps), not an interpolation +// parameter. +#let _alloc-column(specs, gaps, H) = { + let budget = H * _FILL + let prefs = specs.map(s => calc.max(s.pref, s.min)) + let body = prefs.fold(0pt, (a, h) => a + h) + let gap-pref = gaps.fold(0pt, (a, g) => a + g.pref) + let gap-min = gaps.fold(0pt, (a, g) => a + g.min) + let base = body + gap-pref + let min-ratio = if gap-pref > 0pt { gap-min / gap-pref } else { 1.0 } + if base <= budget { + let heights = prefs + let free = budget - base + let frozen = specs.map(s => s.grow <= 0) + while free > 0.05pt { + let gsum = 0.0 + for (i, s) in specs.enumerate() { + if not frozen.at(i) { gsum += s.grow } + } + if gsum == 0 { break } + let spent = 0pt + for (i, s) in specs.enumerate() { + if frozen.at(i) { continue } + let add = free * (s.grow / gsum) + if s.max != none and heights.at(i) + add >= s.max { + add = calc.max(s.max - heights.at(i), 0pt) + frozen.at(i) = true + } + heights.at(i) += add + spent += add + } + free -= spent + if spent <= 0.05pt { break } + } + let used = heights.fold(0pt, (a, h) => a + h) + gap-pref + (heights: heights, gaps: gaps.map(g => g.pref), + y0: H * _MARGIN-TOP + calc.max(budget - used, 0pt) / 2, + fit: (state: "normal", required_height_ratio: base / H, gap_ratio: 1.0, + margin_deficit_ratio: 0.0, body_overflow_ratio: 0.0)) + } else if body + gap-min <= budget { + let span = gap-pref - gap-min + let t = if span > 0pt { (budget - body - gap-min) / span } else { 1.0 } + let actual = gaps.map(g => g.min + (g.pref - g.min) * t) + let actual-sum = actual.fold(0pt, (a, g) => a + g) + (heights: prefs, gaps: actual, + y0: H * _MARGIN-TOP, + fit: (state: "compressed", required_height_ratio: base / H, + gap_ratio: if gap-pref > 0pt { actual-sum / gap-pref } else { 1.0 }, + margin_deficit_ratio: 0.0, body_overflow_ratio: 0.0)) + } else if body + gap-min <= H { + let total = body + gap-min + (heights: prefs, gaps: gaps.map(g => g.min), y0: (H - total) / 2, + fit: (state: "tight", required_height_ratio: base / H, gap_ratio: min-ratio, + margin_deficit_ratio: (total - budget) / H, body_overflow_ratio: 0.0)) + } else { + let total = body + gap-min + (heights: prefs, gaps: gaps.map(g => g.min), y0: 0pt, + fit: (state: "overflow", required_height_ratio: base / H, gap_ratio: min-ratio, + margin_deficit_ratio: (H - budget) / H, body_overflow_ratio: (total - H) / H)) + } +} + +// Row variant: one shared height for side-by-side cards. No gaps to +// compress, so the states are normal / tight / overflow and gap_ratio is 1. +#let _fit-row(naturals, H) = { + let natural = naturals.fold(0pt, (a, h) => calc.max(a, h)) + let budget = H * _FILL + if natural <= budget { + let row = _clamp(natural, _CARD-FILL-MIN * H, budget) + (row: row, y: H * _MARGIN-TOP + (budget - row) / 2, + fit: (state: "normal", required_height_ratio: natural / H, gap_ratio: 1.0, + margin_deficit_ratio: 0.0, body_overflow_ratio: 0.0)) + } else if natural <= H { + (row: natural, y: (H - natural) / 2, + fit: (state: "tight", required_height_ratio: natural / H, gap_ratio: 1.0, + margin_deficit_ratio: (natural - budget) / H, body_overflow_ratio: 0.0)) + } else { + (row: natural, y: 0pt, + fit: (state: "overflow", required_height_ratio: natural / H, gap_ratio: 1.0, + margin_deficit_ratio: (H - budget) / H, body_overflow_ratio: (natural - H) / H)) + } +} + +// --------------------------------------------------------------------------- +// measurement +// --------------------------------------------------------------------------- + +// Content signatures (verified against typst 0.15 `measure`): +// * `v(...)` and an empty string have zero unconstrained WIDTH; +// * a bare horizontal rule has zero constrained HEIGHT; +// * percent-width media (`image(width: 100%)`) measures exactly like a +// spacer — zero unconstrained width — so only visual() slots may fall +// back to the slot width, and that payload is marked "declared"; +// * `hide(...)` keeps its full layout size and can only be caught by the +// pixel cross-check. +// Text slots (card / takeaway / plain / metric fields) therefore require a +// positive unconstrained width AND a positive constrained height; visuals +// only panic when every dimension is zero (genuinely empty content). +#let _assert-measurable(comp, slot, kind, body, w) = { + let mu = measure(body) + let mc = measure(block(width: w, body)) + if kind == "visual" { + if mu.width < _EPS-L and mu.height < _EPS-L and mc.height < _EPS-L { + panic(comp + ": " + slot + " renders empty — pass real content; percent-sized " + + "content must be declared visual() (fit: \"stretch\")") + } + } else { + if mu.width < _EPS-L { + panic(comp + ": " + slot + " has no measurable width — spacers and empty " + + "strings are not content, and percent-width media belongs in visual()") + } + if mc.height < _EPS-L { + panic(comp + ": " + slot + " has no measurable height — a bare rule is " + + "decoration, not content") + } + } + (mu: mu, mc: mc) +} + +// Horizontal-overflow evidence. Unbreakable content wider than its slot +// cannot wrap: the constrained height (`mc`) stays at the unconstrained +// height (`mu`) while the natural width exceeds the slot width `w`. Wrapped +// text is clamped honestly instead. +#let _overflows-x(mu, mc, w) = mu.width > w + _EPS-L and mc.height <= mu.height + _EPS-L + +// Measure a natural item for a slot of width `w`. Returns the outer size the +// allocator uses (including card padding), the 2-D payload flow bbox, the +// payload source, and horizontal-overflow evidence. +#let _measure-item(comp, slot, it, w) = { + let pad = if _painted(it) { _CARD-PAD.to-absolute() } else { 0pt } + let iw = w - 2 * pad + let m = _assert-measurable(comp, slot, it.kind, it.body, iw) + // Only reachable for visual items: percent-width media measures at zero + // width, claims the slot width, and is marked "declared" so the checker + // demands pixel evidence instead of counting it as measured payload. + let fallback = m.mu.width < _EPS-L + ( + outer: m.mc.height + 2 * pad, + pay-w: if fallback { iw } else { calc.min(m.mu.width, iw) }, + pay-h: m.mc.height, + pad: pad, + src: if fallback { "declared" } else { "measured" }, + over-x: _overflows-x(m.mu, m.mc, iw), + ) +} + +// Build the allocator spec and telemetry seed for one column/slot item. +// Stretch visuals get the hard minimum and a grow weight; natural items are +// pinned at their measured size (max = pref) unless the component grants +// cards a grow weight. +#let _item-slot(comp, slot, it, w, H) = { + if _stretchy(it) { + (stretch: true, painted: false, pad: 0pt, + spec: (min: H * _VISUAL-MIN, pref: H * _VISUAL-MIN, max: none, grow: 1.0), + pay-w: none, pay-h: none, src: "declared", over-x: false) + } else { + let m = _measure-item(comp, slot, it, w) + (stretch: false, painted: _painted(it), pad: m.pad, + spec: (min: m.outer, pref: m.outer, max: m.outer, grow: 0.0), + pay-w: m.pay-w, pay-h: m.pay-h, src: m.src, over-x: m.over-x) + } +} + +// --------------------------------------------------------------------------- +// telemetry primitives (schema v4) +// --------------------------------------------------------------------------- + +// One telemetry object. +// frame — the box the component allocated (normalized to the body) +// preferred — the natural outer size the allocator saw +// payload — 2-D flow bbox of the inner content (inside card +// padding); approximate for wrapped text (flow bbox, not +// glyph ink); equals the padded frame for declared-stretch +// content +// paint — the visible card box, or none for unpainted objects +// paint_fill — hex colour of the card fill (pixel-check evidence) +// payload_source — "measured" | "declared"; declared payloads are claims +// the checker verifies against pixels, never evidence +// overflow_x — unbreakable content wider than the slot (see +// _measure-item) +// Payload height is NOT clamped to the frame: overflowing content reports a +// payload that runs past its frame, and the checker sees it. +#let _obj( + id, kind, role, + fx, fy, fw, fh, + pref-h: none, + pay-w: none, pay-h: none, + pay-src: "measured", over-x: false, + pad-x: 0.0, pad-y: 0.0, + calign: "center", halign: "center", + painted: false, paint-fill: none, visible-from: 1, +) = { + let ix = fx + pad-x + let iy = fy + pad-y + let iw = calc.max(fw - 2 * pad-x, 0.0) + let ih = calc.max(fh - 2 * pad-y, 0.0) + let sx = if pay-w == none { "stretch" } else { "natural" } + let sy = if pay-h == none { "stretch" } else { "natural" } + let pw = if pay-w == none { iw } else { calc.min(pay-w, iw) } + let ph = if pay-h == none { ih } else { pay-h } + let px = if sx == "stretch" or halign == "left" { ix } else { ix + (iw - pw) / 2 } + let py = if sy == "stretch" or calign == "top" { iy } else { iy + (ih - ph) / 2 } + ( + id: id, + object_kind: kind, + semantic_role: role, + frame: (x: fx, y: fy, w: fw, h: fh), + preferred: (w: fw, h: if pref-h == none { fh } else { pref-h }), + payload: (x: px, y: py, w: pw, h: ph), + paint: if painted { (x: fx, y: fy, w: fw, h: fh) } else { none }, + paint_fill: if painted { paint-fill } else { none }, + payload_source: if pay-w == none or pay-h == none { "declared" } else { pay-src }, + overflow_x: over-x, + sizing: (x: sx, y: sy), + visible_from: visible-from, + ) +} + +#let _rel(from, to, kind, axis, proximity) = ( + from: from, + to: to, + kind: kind, + axis: axis, + desired_proximity: proximity, +) + +// One frame mapping per physically rendered subslide, carrying the physical +// body geometry (pt) so the pixel checker maps normalized telemetry onto the +// render without hard-coded page constants. In handout mode only the +// surviving subslide emits, so coverage counts real pages. +#let _frame-mark(sid, self, size) = { + let pos = here().position() + [#metadata(( + schema: "xwysyy-frame/v2", + id: sid, + step: self.subslide, + steps: self.repeat, + page: pos.page, + handout: self.handout, + body: (x: pos.x.pt(), y: pos.y.pt(), w: size.width.pt(), h: size.height.pt()), + page_size: (w: page.width.pt(), h: page.height.pt()), + )) ] +} + +// Emit one slide's telemetry. Must be produced in markup so the label binds +// (a label on `metadata(...)` in code mode is a Typst syntax error). +#let _emit(id, archetype, engine, page, frame-count, objects, relations, fit, extra) = [ + #metadata(( + schema: "xwysyy-slide-layout/v4", + id: id, + archetype: archetype, + layout_engine: engine, + page: page, + frame_count: frame-count, + coordinate_system: "normalized-slide-body", + objects: objects, + relations: relations, + fit: fit, + extra: extra, + )) +] + +// Debug overlay: solid rect = frame, dashed rect = payload bbox. +#let _debug-palette = ( + rgb("#d7263d"), rgb("#2e86ab"), rgb("#4f772d"), + rgb("#7b2cbf"), rgb("#f77f00"), rgb("#006d77"), +) +#let _debug-layer(objects, W, H) = { + for (i, o) in objects.enumerate() { + let c = _debug-palette.at(calc.rem(i, _debug-palette.len())) + let f = o.frame + place(_atop + _aleft, dx: W * f.x, dy: H * f.y, rect( + width: W * f.w, height: H * f.h, + stroke: (paint: c, thickness: 0.7pt), fill: c.transparentize(93%), + )) + if o.sizing.y == "natural" or o.sizing.x == "natural" { + let p = o.payload + place(_atop + _aleft, dx: W * p.x, dy: H * p.y, rect( + width: W * p.w, height: H * p.h, + stroke: (paint: c, thickness: 0.5pt, dash: "dashed"), + )) + } + place(_atop + _aleft, dx: W * f.x + 0.2em, dy: H * f.y + 0.12em, + text(fill: c, size: 0.5em, weight: "bold", o.id)) + } +} + +// Card container for slot rendering: the theme's rounded skyll box; `calign` +// controls where content sits inside a fixed-height box. +#let _card-box(cw, h, fill, body, calign: horizon) = { + block(width: cw, height: h, fill: fill, inset: _CARD-PAD, radius: 0.4em, align(calign, body)) +} + +// Render one typed item into its allocated slot. +#let _render-item(it, w, h, fill, calign: horizon) = { + if _painted(it) { + _card-box(w, h, fill, it.body, calign: calign) + } else { + block(width: w, height: h, align(center + calign, it.body)) + } +} + +// Resolve the slide id: `auto` becomes "@s" from touying's logical +// slide counter, which is STABLE across reveal subslides and handout mode +// (the physical page number is not: it changes per subslide and would detach +// early frames from their layout record). Must run inside a context. +#let _sid(id, base) = { + if id == auto { base + "@s" + str(utils.slide-counter.get().first()) } else { id } +} + +// --------------------------------------------------------------------------- +// duo-slide — one vertical semantic pair (e.g. figure over takeaway) +// --------------------------------------------------------------------------- +// +// `top` coerces plain content to visual(fit: "natural"); wrap percent-height +// content in visual() to make it the growing dominant block. `bottom` +// coerces plain content to card() (painted). + +#let duo-slide( + title: auto, + id: auto, + top: none, + bottom: none, + mode: "balanced", + relation: "supports", + reveal: false, + debug: false, + tuning: (:), +) = { + _check-mode(mode) + _check-relation("duo-slide", relation) + _require("duo-slide", "top", top) + _require("duo-slide", "bottom", bottom) + let tn = _tuning("duo-slide", tuning, ( + "top-width": (default: 0.82, lo: 0.3, hi: 0.95), + "bottom-width": (default: 0.74, lo: 0.3, hi: 0.95), + )) + let ti = _validate-item("duo-slide", "top", _as-item(top, "visual")) + let bi = _validate-item("duo-slide", "bottom", _as-item(bottom, "card")) + let steps = _steps("duo-slide", (ti, bi), reveal, 2) + let rep = calc.max(..steps, 1) + xwysyy-slide(title: title, repeat: rep, self => context { + let cfill = _theme-state.get().skyll + layout(size => { + let W = size.width + let H = size.height + let sid = _sid(id, "duo") + _frame-mark(sid, self, size) + let twn = tn.at("top-width") + let bwn = tn.at("bottom-width") + let tw = W * twn + let bw = W * bwn + let gap = H * _mode-gap(mode) + let ts = _item-slot("duo-slide", "top", ti, tw, H) + let bs = _item-slot("duo-slide", "bottom", bi, bw, H) + let alloc = _alloc-column((ts.spec, bs.spec), ((min: gap * _GAP-MIN, pref: gap),), H) + let th = alloc.heights.at(0) + let bh = alloc.heights.at(1) + let g = alloc.gaps.at(0) + let y0 = alloc.y0 + + place(_atop + _aleft, dx: (W - tw) / 2, dy: y0, + _from(self, steps.at(0), _render-item(ti, tw, th, cfill))) + place(_atop + _aleft, dx: (W - bw) / 2, dy: y0 + th + g, + _from(self, steps.at(1), _render-item(bi, bw, bh, cfill))) + + let objects = ( + _obj(sid + ":top", ti.kind, ti.role, + (1.0 - twn) / 2, y0 / H, twn, th / H, + pref-h: ts.spec.pref / H, + pay-w: if ts.stretch { none } else { ts.pay-w / W }, + pay-h: if ts.stretch { none } else { ts.pay-h / H }, + pay-src: ts.src, over-x: ts.over-x, + pad-x: ts.pad / W, pad-y: ts.pad / H, + halign: if ts.painted { "left" } else { "center" }, + painted: ts.painted, paint-fill: cfill.to-hex(), + visible-from: steps.at(0)), + _obj(sid + ":bottom", bi.kind, bi.role, + (1.0 - bwn) / 2, (y0 + th + g) / H, bwn, bh / H, + pref-h: bs.spec.pref / H, + pay-w: if bs.stretch { none } else { bs.pay-w / W }, + pay-h: if bs.stretch { none } else { bs.pay-h / H }, + pay-src: bs.src, over-x: bs.over-x, + pad-x: bs.pad / W, pad-y: bs.pad / H, + halign: if bs.painted { "left" } else { "center" }, + painted: bs.painted, paint-fill: cfill.to-hex(), + visible-from: steps.at(1)), + ) + let relations = ( + _rel(sid + ":top", sid + ":bottom", relation, "vertical", _mode-proximity(mode)), + ) + if self.subslide == rep { + _emit(sid, "duo", "column", here().position().page, rep, objects, relations, + alloc.fit, (mode: mode, tuned: tuning.len() > 0)) + if debug { _debug-layer(objects, W, H) } + } + }) + }) +} + +// --------------------------------------------------------------------------- +// focus-slide — a single centered block for low-content pages +// --------------------------------------------------------------------------- +// +// The one deliberate exception to fill-first: a focus page states a single +// point and is allowed its symmetric whitespace. Plain content is coerced to +// card(). A focus page has exactly one frame: it rejects reveal-from beyond +// step 1 and stretch visuals (there is no free space to absorb). Fit still honours the +// safe area: content taller than it reports tight, taller than the page +// reports overflow. + +#let focus-slide( + title: auto, + id: auto, + body: none, + debug: false, + tuning: (:), +) = { + _require("focus-slide", "body", body) + let tn = _tuning("focus-slide", tuning, ( + "width": (default: 0.76, lo: 0.3, hi: 0.95), + "center-y": (default: _OPTICAL-CENTER, lo: 0.30, hi: 0.70), + )) + let it = _validate-item("focus-slide", "body", _as-item(body, "card"), + stretch: false, reveal: false) + xwysyy-slide(title: title, self => context { + let cfill = _theme-state.get().skyll + layout(size => { + let W = size.width + let H = size.height + let sid = _sid(id, "focus") + _frame-mark(sid, self, size) + let wn = tn.at("width") + let cw = W * wn + let m = _measure-item("focus-slide", "body", it, cw) + let hr = m.outer / H + let fit = if hr <= _FILL { + (state: "normal", required_height_ratio: hr, gap_ratio: 1.0, + margin_deficit_ratio: 0.0, body_overflow_ratio: 0.0) + } else if hr <= 1.0 { + (state: "tight", required_height_ratio: hr, gap_ratio: 1.0, + margin_deficit_ratio: hr - _FILL, body_overflow_ratio: 0.0) + } else { + (state: "overflow", required_height_ratio: hr, gap_ratio: 1.0, + margin_deficit_ratio: 1.0 - _FILL, body_overflow_ratio: hr - 1.0) + } + let y = if fit.state == "normal" { + _clamp(tn.at("center-y") - hr / 2, _MARGIN-TOP, + calc.max(1.0 - _MARGIN-BOT - hr, _MARGIN-TOP)) + } else if fit.state == "tight" { (1.0 - hr) / 2 } else { 0.0 } + + place(_atop + _aleft, dx: (W - cw) / 2, dy: H * y, + _render-item(it, cw, m.outer, cfill)) + + let objects = ( + _obj(sid + ":focus", it.kind, it.role, + (1.0 - wn) / 2, y, wn, hr, + pref-h: hr, + pay-w: m.pay-w / W, pay-h: m.pay-h / H, + pay-src: m.src, over-x: m.over-x, + pad-x: m.pad / W, pad-y: m.pad / H, + halign: if _painted(it) { "left" } else { "center" }, + painted: _painted(it), paint-fill: cfill.to-hex()), + ) + _emit(sid, "focus", "single", here().position().page, 1, objects, (), fit, + (tuned: tuning.len() > 0)) + if debug { _debug-layer(objects, W, H) } + }) + }) +} + +// --------------------------------------------------------------------------- +// stack-slide — N vertical blocks, fill-first (duo generalized) +// --------------------------------------------------------------------------- +// +// Takes typed items; plain content is coerced to card(). Stretch visuals +// absorb the free space; a stack with no stretch visual grows its cards so +// every card reads tall. With `reveal: true` item i appears on subslide i +// (override per item with `reveal-from`). + +#let stack-slide( + title: auto, + id: auto, + items: (), + relation: "supports", + mode: "balanced", + reveal: false, + debug: false, + tuning: (:), +) = { + _check-mode(mode) + _check-relation("stack-slide", relation) + let tn = _tuning("stack-slide", tuning, ( + "width": (default: 0.82, lo: 0.3, hi: 0.95), + )) + let its = items.enumerate().map(((i, x)) => + _validate-item("stack-slide", "items[" + str(i) + "]", _as-item(x, "card"))) + let n = its.len() + if n == 0 { + panic("stack-slide: items is empty") + } + let steps = _steps("stack-slide", its, reveal, n) + let rep = calc.max(..steps, 1) + xwysyy-slide(title: title, repeat: rep, self => context { + let cfill = _theme-state.get().skyll + layout(size => { + let W = size.width + let H = size.height + let sid = _sid(id, "stack") + _frame-mark(sid, self, size) + let wn = tn.at("width") + let bw = W * wn + let slots = its.enumerate().map(((i, it)) => + _item-slot("stack-slide", "items[" + str(i) + "]", it, bw, H)) + // Grow policy: stretch visuals absorb free space; without one, the + // cards share it so a pure-text stack reads as tall cards. + let has-stretch = slots.any(s => s.stretch) + let specs = slots.enumerate().map(((i, s)) => { + if s.stretch { s.spec } + else if not has-stretch and its.at(i).kind == "card" { + (min: s.spec.min, pref: s.spec.pref, max: none, grow: 1.0) + } else { s.spec } + }) + let gap = H * _mode-gap(mode) + let alloc = _alloc-column(specs, ((min: gap * _GAP-MIN, pref: gap),) * (n - 1), H) + + let objects = () + let relations = () + let cy = alloc.y0 + for (i, it) in its.enumerate() { + let h = alloc.heights.at(i) + let s = slots.at(i) + place(_atop + _aleft, dx: (W - bw) / 2, dy: cy, + _from(self, steps.at(i), _render-item(it, bw, h, cfill))) + let oid = sid + ":" + str(i) + objects.push(_obj(oid, it.kind, it.role, + (1.0 - wn) / 2, cy / H, wn, h / H, + pref-h: specs.at(i).pref / H, + pay-w: if s.stretch { none } else { s.pay-w / W }, + pay-h: if s.stretch { none } else { s.pay-h / H }, + pay-src: s.src, over-x: s.over-x, + pad-x: s.pad / W, pad-y: s.pad / H, + halign: if s.painted { "left" } else { "center" }, + painted: s.painted, paint-fill: cfill.to-hex(), + visible-from: steps.at(i))) + if i > 0 { + relations.push(_rel(sid + ":" + str(i - 1), oid, relation, "vertical", _mode-proximity(mode))) + } + cy = cy + h + if i < n - 1 { alloc.gaps.at(i) } else { 0pt } + } + if self.subslide == rep { + _emit(sid, "stack", "column", here().position().page, rep, objects, relations, + alloc.fit, (mode: mode, tuned: tuning.len() > 0)) + if debug { _debug-layer(objects, W, H) } + } + }) + }) +} + +// --------------------------------------------------------------------------- +// grid-slide — N equal-height peer columns (N >= 2) +// --------------------------------------------------------------------------- +// +// Columns take typed items; plain content is coerced to card(). Row layouts +// are sized by their natural height, so stretch visuals are rejected up +// front. Consecutive columns carry a `peer` relation so the checker +// validates the gutter. + +#let grid-slide( + title: auto, + id: auto, + columns: (), + reveal: false, + debug: false, + tuning: (:), +) = { + let tn = _tuning("grid-slide", tuning, ( + "gutter": (default: 0.04, lo: 0.0, hi: 0.2), + )) + let its = columns.enumerate().map(((i, x)) => + _validate-item("grid-slide", "columns[" + str(i) + "]", _as-item(x, "card"), + stretch: false)) + let n = its.len() + if n < 2 { + panic("grid-slide: needs at least 2 columns (use stack-slide or focus-slide for one block)") + } + let steps = _steps("grid-slide", its, reveal, n) + let rep = calc.max(..steps, 1) + xwysyy-slide(title: title, repeat: rep, self => context { + let cfill = _theme-state.get().skyll + layout(size => { + let W = size.width + let H = size.height + let sid = _sid(id, "grid") + _frame-mark(sid, self, size) + let gutter = tn.at("gutter") + let cwn = (1.0 - gutter * (n - 1)) / n + if cwn <= 0.0 { + panic("grid-slide: gutter " + repr(gutter) + " leaves no width for " + repr(n) + " columns") + } + let cw = W * cwn + let slots = its.enumerate().map(((i, it)) => + _item-slot("grid-slide", "columns[" + str(i) + "]", it, cw, H)) + let naturals = slots.map(s => s.spec.pref) + let row = _fit-row(naturals, H) + + let objects = () + let relations = () + for (i, it) in its.enumerate() { + let s = slots.at(i) + let xn = i * (cwn + gutter) + place(_atop + _aleft, dx: W * xn, dy: row.y, + _from(self, steps.at(i), _render-item(it, cw, row.row, cfill))) + let oid = sid + ":" + str(i) + objects.push(_obj(oid, it.kind, it.role, + xn, row.y / H, cwn, row.row / H, + pref-h: s.spec.pref / H, + pay-w: s.pay-w / W, pay-h: s.pay-h / H, + pay-src: s.src, over-x: s.over-x, + pad-x: s.pad / W, pad-y: s.pad / H, + halign: if s.painted { "left" } else { "center" }, + painted: s.painted, paint-fill: cfill.to-hex(), + visible-from: steps.at(i))) + if i > 0 { + relations.push(_rel(sid + ":" + str(i - 1), oid, "peer", "horizontal", "gutter")) + } + } + if self.subslide == rep { + _emit(sid, "grid", "row", here().position().page, rep, objects, relations, row.fit, + (tuned: tuning.len() > 0)) + if debug { _debug-layer(objects, W, H) } + } + }) + }) +} + +// --------------------------------------------------------------------------- +// compare-slide — two equal-height cards read as a contrast +// --------------------------------------------------------------------------- +// +// Cards share one row height; content is TOP-aligned so the two openings sit +// on the same line, which is how a contrast is read. Both sides are +// required; plain content is coerced to card(); stretch visuals are rejected +// up front (row layouts are sized by their natural height). + +#let compare-slide( + title: auto, + id: auto, + left: none, + right: none, + reveal: false, + debug: false, + tuning: (:), +) = { + _require("compare-slide", "left", left) + _require("compare-slide", "right", right) + let tn = _tuning("compare-slide", tuning, ( + "gutter": (default: 0.06, lo: 0.0, hi: 0.2), + )) + let li = _validate-item("compare-slide", "left", _as-item(left, "card"), stretch: false) + let ri = _validate-item("compare-slide", "right", _as-item(right, "card"), stretch: false) + let steps = _steps("compare-slide", (li, ri), reveal, 2) + let rep = calc.max(..steps, 1) + xwysyy-slide(title: title, repeat: rep, self => context { + let cfill = _theme-state.get().skyll + layout(size => { + let W = size.width + let H = size.height + let sid = _sid(id, "compare") + _frame-mark(sid, self, size) + let gutter = tn.at("gutter") + let cwn = (1.0 - gutter) / 2 + let cw = W * cwn + let ls = _item-slot("compare-slide", "left", li, cw, H) + let rs = _item-slot("compare-slide", "right", ri, cw, H) + let row = _fit-row((ls.spec.pref, rs.spec.pref), H) + + place(_atop + _aleft, dx: 0pt, dy: row.y, + _from(self, steps.at(0), _render-item(li, cw, row.row, cfill, calign: _atop))) + place(_atop + _aleft, dx: W * (cwn + gutter), dy: row.y, + _from(self, steps.at(1), _render-item(ri, cw, row.row, cfill, calign: _atop))) + + let objects = ( + _obj(sid + ":left", li.kind, li.role, + 0.0, row.y / H, cwn, row.row / H, + pref-h: ls.spec.pref / H, + pay-w: ls.pay-w / W, pay-h: ls.pay-h / H, + pay-src: ls.src, over-x: ls.over-x, + pad-x: ls.pad / W, pad-y: ls.pad / H, + calign: "top", halign: if ls.painted { "left" } else { "center" }, + painted: ls.painted, paint-fill: cfill.to-hex(), + visible-from: steps.at(0)), + _obj(sid + ":right", ri.kind, ri.role, + cwn + gutter, row.y / H, cwn, row.row / H, + pref-h: rs.spec.pref / H, + pay-w: rs.pay-w / W, pay-h: rs.pay-h / H, + pay-src: rs.src, over-x: rs.over-x, + pad-x: rs.pad / W, pad-y: rs.pad / H, + calign: "top", halign: if rs.painted { "left" } else { "center" }, + painted: rs.painted, paint-fill: cfill.to-hex(), + visible-from: steps.at(1)), + ) + let relations = ( + _rel(sid + ":left", sid + ":right", "contrast", "horizontal", "gutter"), + ) + if self.subslide == rep { + _emit(sid, "compare", "row", here().position().page, rep, objects, relations, row.fit, + (tuned: tuning.len() > 0)) + if debug { _debug-layer(objects, W, H) } + } + }) + }) +} + +// --------------------------------------------------------------------------- +// stat-slide — a row of metric tiles (big value + label) +// --------------------------------------------------------------------------- +// +// Standalone row engine (shares `_fit-row`), so the value auto-shrink scale +// is measured here and exported in the telemetry. `stats` takes typed +// `metric(value, label, reveal-from: ...)` entries; the value and the label +// are measured separately (never through the tile's forced width) and both +// must render non-empty. + +#let metric(value, label, reveal-from: auto) = { + if value == none { panic("metric: value is required") } + if label == none { panic("metric: label is required") } + (xwysyy-metric: true, value: value, label: label, reveal-from: reveal-from) +} + +#let stat-slide( + title: auto, + id: auto, + stats: (), + reveal: false, + debug: false, + tuning: (:), +) = { + let n = stats.len() + if n == 0 { + panic("stat-slide: stats is empty") + } + for (i, s) in stats.enumerate() { + if type(s) != dictionary or not s.at("xwysyy-metric", default: false) { + panic("stat-slide: stats[" + str(i) + "] must be built with metric(value, label)") + } + if s.at("value", default: none) == none or s.at("label", default: none) == none { + panic("stat-slide: stats[" + str(i) + "] must carry both value and label") + } + } + let tn = _tuning("stat-slide", tuning, ( + "gutter": (default: 0.04, lo: 0.0, hi: 0.2), + )) + let steps = _steps("stat-slide", stats, reveal, n) + let rep = calc.max(..steps, 1) + // Both metric fields must render non-empty (an empty string still has line + // height, but zero width). + let assert-metric(i, s) = { + if measure(text(weight: 700, s.value)).width < _EPS-L { + panic("stat-slide: stats[" + str(i) + "] value renders empty") + } + if measure(s.label).width < _EPS-L { + panic("stat-slide: stats[" + str(i) + "] label renders empty") + } + } + xwysyy-slide(title: title, repeat: rep, self => context { + let t = _theme-state.get() + layout(size => { + let W = size.width + let H = size.height + let sid = _sid(id, "stat") + _frame-mark(sid, self, size) + for (i, s) in stats.enumerate() { assert-metric(i, s) } + let gutter = tn.at("gutter") + let cwn = (1.0 - gutter * (n - 1)) / n + if cwn <= 0.0 { + panic("stat-slide: gutter " + repr(gutter) + " leaves no width for " + repr(n) + " tiles") + } + let cw = W * cwn + let pad = _CARD-PAD.to-absolute() + let iw = cw - 2 * pad + // Fit each value to its tile: shrink down to the floor scale, below + // which the value wraps and the tile grows (reported by fit). + let scales = stats.map(s => { + let vw = measure(text(size: 2.6em, weight: 700, s.value)).width + if vw > iw and vw > 0pt { calc.max(iw / vw, _STAT-SCALE-MIN) } else { 1.0 } + }) + let tile(i) = { + let s = stats.at(i) + align(center + horizon, stack( + spacing: 0.2em, + align(center, text(size: 2.6em * scales.at(i), weight: 700, fill: t.sea, s.value)), + align(center, text(size: 0.95em, fill: t.sea.lighten(12%), s.label)), + )) + } + // Payload width comes from the value and the label measured + // separately, never from the tile's forced full width. + let pays = range(n).map(i => { + let s = stats.at(i) + let vstyled = text(size: 2.6em * scales.at(i), weight: 700, s.value) + let vm = measure(vstyled) + let lm = measure(text(size: 0.95em, s.label)) + let vc = measure(block(width: iw, vstyled)) + ( + w: calc.max(vm.width, lm.width), + h: measure(block(width: iw, tile(i))).height, + over-x: _overflows-x(vm, vc, iw), + ) + }) + let naturals = pays.map(p => p.h + 2 * pad) + let row = _fit-row(naturals, H) + + let objects = () + let relations = () + for i in range(n) { + let xn = i * (cwn + gutter) + place(_atop + _aleft, dx: W * xn, dy: row.y, + _from(self, steps.at(i), + block(width: cw, height: row.row, fill: t.skyll, inset: _CARD-PAD, + radius: 0.4em, align(horizon, tile(i))))) + let oid = sid + ":" + str(i) + objects.push(_obj(oid, "card", "metric", + xn, row.y / H, cwn, row.row / H, + pref-h: naturals.at(i) / H, + pay-w: calc.min(pays.at(i).w, iw) / W, pay-h: pays.at(i).h / H, + pay-src: "measured", over-x: pays.at(i).over-x, + pad-x: pad / W, pad-y: pad / H, + painted: true, paint-fill: t.skyll.to-hex(), + visible-from: steps.at(i))) + if i > 0 { + relations.push(_rel(sid + ":" + str(i - 1), oid, "peer", "horizontal", "gutter")) + } + } + if self.subslide == rep { + _emit(sid, "stat", "row", here().position().page, rep, objects, relations, row.fit, ( + value_scales: scales, + tuned: tuning.len() > 0, + )) + if debug { _debug-layer(objects, W, H) } + } + }) + }) +} + +// --------------------------------------------------------------------------- +// figure-slide — figure, tight caption, optional takeaway +// --------------------------------------------------------------------------- +// +// The caption is measured first and subtracted, then the figure slot receives +// an explicit height, so a stretch figure fills its slot instead of escaping +// the wrapper (Typst measures percentage heights as 0 in an unbounded +// context). `fig` coerces plain content to visual(fit: "natural"); wrap it +// in visual() to make it fill. `takeaway` coerces to a painted takeaway card +// and rejects stretch. Reveal goes through the shared resolver: with +// `reveal: true` the figure shows first and the takeaway follows; per-item +// `reveal-from` overrides that order, and the caption always follows the +// figure. + +#let figure-slide( + title: auto, + id: auto, + fig: none, + caption: none, + takeaway: none, + mode: "balanced", + reveal: false, + debug: false, + tuning: (:), +) = { + _check-mode(mode) + _require("figure-slide", "fig", fig) + let tn = _tuning("figure-slide", tuning, ( + "figure-width": (default: 0.80, lo: 0.3, hi: 0.95), + "takeaway-width": (default: 0.74, lo: 0.3, hi: 0.95), + )) + let fi = _validate-item("figure-slide", "fig", _as-item(fig, "visual")) + let ki = if takeaway == none { none } else { + _validate-item("figure-slide", "takeaway", _as-item(takeaway, "takeaway"), stretch: false) + } + let items = if ki == none { (fi,) } else { (fi, ki) } + let steps = _steps("figure-slide", items, reveal, items.len()) + let fig-step = steps.at(0) + let tk-step = if ki != none { steps.at(1) } else { 1 } + let rep = calc.max(..steps, 1) + let cap = if caption == none { none } else { + context text(size: 0.85em, fill: _theme-state.get().sea.lighten(12%), style: "italic", caption) + } + xwysyy-slide(title: title, repeat: rep, self => context { + let cfill = _theme-state.get().skyll + layout(size => { + let W = size.width + let H = size.height + let sid = _sid(id, "figure") + _frame-mark(sid, self, size) + let fwn = tn.at("figure-width") + let twn = tn.at("takeaway-width") + let fw = W * fwn + let tw = W * twn + let cap-gap = 0.5em.to-absolute() + let mode-gap = H * _mode-gap(mode) + + let fs = _item-slot("figure-slide", "fig", fi, fw, H) + let specs = (fs.spec,) + let gaps = () + let cap-m = if cap != none { + let m = measure(block(width: fw, cap)) + specs.push((min: m.height, pref: m.height, max: m.height, grow: 0.0)) + // The caption gap is tight by design and not compressible. + gaps.push((min: cap-gap, pref: cap-gap)) + m + } else { none } + let ks = if ki != none { + let s = _item-slot("figure-slide", "takeaway", ki, tw, H) + specs.push(s.spec) + gaps.push((min: mode-gap * _GAP-MIN, pref: mode-gap)) + s + } else { none } + let alloc = _alloc-column(specs, gaps, H) + + let objects = () + let relations = () + let cy = alloc.y0 + let idx = 0 + let fh = alloc.heights.at(idx) + place(_atop + _aleft, dx: (W - fw) / 2, dy: cy, + _from(self, fig-step, _render-item(fi, fw, fh, cfill))) + objects.push(_obj(sid + ":figure", fi.kind, fi.role, + (1.0 - fwn) / 2, cy / H, fwn, fh / H, + pref-h: fs.spec.pref / H, + pay-w: if fs.stretch { none } else { fs.pay-w / W }, + pay-h: if fs.stretch { none } else { fs.pay-h / H }, + pay-src: fs.src, over-x: fs.over-x, + pad-x: fs.pad / W, pad-y: fs.pad / H, + painted: fs.painted, paint-fill: cfill.to-hex(), + visible-from: fig-step)) + cy = cy + fh + idx += 1 + if cap != none { + cy = cy + alloc.gaps.at(idx - 1) + let ch = alloc.heights.at(idx) + place(_atop + _aleft, dx: (W - fw) / 2, dy: cy, + _from(self, fig-step, block(width: fw, align(center, cap)))) + let cm = measure(cap) + objects.push(_obj(sid + ":caption", "plain", "caption", + (1.0 - fwn) / 2, cy / H, fwn, ch / H, + pref-h: ch / H, + pay-w: calc.min(cm.width, fw) / W, pay-h: cap-m.height / H, + pay-src: "measured", + over-x: _overflows-x(cm, cap-m, fw), + visible-from: fig-step)) + relations.push(_rel(sid + ":figure", sid + ":caption", "caption", "vertical", "tight")) + cy = cy + ch + idx += 1 + } + if ki != none { + cy = cy + alloc.gaps.at(idx - 1) + let th = alloc.heights.at(idx) + place(_atop + _aleft, dx: (W - tw) / 2, dy: cy, + _from(self, tk-step, _render-item(ki, tw, th, cfill))) + let anchor = if cap != none { sid + ":caption" } else { sid + ":figure" } + objects.push(_obj(sid + ":takeaway", ki.kind, ki.role, + (1.0 - twn) / 2, cy / H, twn, th / H, + pref-h: ks.spec.pref / H, + pay-w: ks.pay-w / W, pay-h: ks.pay-h / H, + pay-src: ks.src, over-x: ks.over-x, + pad-x: ks.pad / W, pad-y: ks.pad / H, + halign: if ks.painted { "left" } else { "center" }, + painted: ks.painted, paint-fill: cfill.to-hex(), + visible-from: tk-step)) + relations.push(_rel(anchor, sid + ":takeaway", "supports", "vertical", _mode-proximity(mode))) + } + if self.subslide == rep { + _emit(sid, "figure", "column", here().position().page, rep, objects, relations, + alloc.fit, (mode: mode, tuned: tuning.len() > 0)) + if debug { _debug-layer(objects, W, H) } + } + }) + }) +} + +// --------------------------------------------------------------------------- +// sidebar-slide — a narrow label tab beside a wide content card +// --------------------------------------------------------------------------- +// +// No `reveal`: the label and its content have no presentation order. Both +// slots take plain content only (the component paints its own boxes; do not +// wrap the body in `textbox` or pass typed items). + +#let sidebar-slide( + title: auto, + id: auto, + label: none, + body: none, + debug: false, + tuning: (:), +) = { + _require("sidebar-slide", "label", label) + _require("sidebar-slide", "body", body) + if type(label) == dictionary or type(body) == dictionary { + panic("sidebar-slide: label and body take plain content, not typed items") + } + let tn = _tuning("sidebar-slide", tuning, ( + "label-width": (default: 0.26, lo: 0.1, hi: 0.5), + "gutter": (default: 0.04, lo: 0.0, hi: 0.2), + )) + // Both slots are text cards: spacers, empty strings, and bare rules panic. + let assert-slot(slot, body, w) = { + let mu = measure(body) + let mc = measure(block(width: w, body)) + if mu.width < _EPS-L { + panic("sidebar-slide: " + slot + " has no measurable width — spacers and " + + "empty strings are not content") + } + if mc.height < _EPS-L { + panic("sidebar-slide: " + slot + " has no measurable height — a bare rule " + + "is decoration, not content") + } + (mu: mu, mc: mc) + } + xwysyy-slide(title: title, self => context { + let t = _theme-state.get() + layout(size => { + let W = size.width + let H = size.height + let sid = _sid(id, "sidebar") + _frame-mark(sid, self, size) + let lwn = tn.at("label-width") + let gutter = tn.at("gutter") + let bwn = 1.0 - lwn - gutter + let lw = W * lwn + let bw = W * bwn + let pad = _CARD-PAD.to-absolute() + // The label is measured with the same styled content it is rendered + // with (bold via `set text`, not `strong`, whose show rule enlarges + // the run and would make the measurement disagree with the render). + let label-inner = { + set text(weight: "bold") + show raw: set text(fill: t.sea) + label + } + let lmm = assert-slot("label", label-inner, lw - 2 * pad) + let bmm = assert-slot("body", body, bw - 2 * pad) + let lh = lmm.mc.height + 2 * pad + let bh = bmm.mc.height + 2 * pad + let row = _fit-row((lh, bh), H) + + place(_atop + _aleft, dx: 0pt, dy: row.y, + block(width: lw, height: row.row, fill: t.sea, inset: pad, radius: 0.4em, + align(horizon + _aleft, { + // Label text is light (paper) on the dark sea tab; inline code + // keeps its light chip but takes dark (sea) text so it stays + // readable instead of light-on-light. + set text(fill: t.paper) + label-inner + }))) + place(_atop + _aleft, dx: W * (lwn + gutter), dy: row.y, + block(width: bw, height: row.row, fill: t.skyll, inset: pad, radius: 0.4em, + align(horizon, body))) + + let objects = ( + _obj(sid + ":label", "card", "label", + 0.0, row.y / H, lwn, row.row / H, + pref-h: lh / H, + pay-w: calc.min(lmm.mu.width, lw - 2 * pad) / W, + pay-h: lmm.mc.height / H, + pay-src: "measured", + over-x: _overflows-x(lmm.mu, lmm.mc, lw - 2 * pad), + pad-x: pad / W, pad-y: pad / H, + halign: "left", painted: true, paint-fill: t.sea.to-hex()), + _obj(sid + ":body", "card", "content", + lwn + gutter, row.y / H, bwn, row.row / H, + pref-h: bh / H, + pay-w: calc.min(bmm.mu.width, bw - 2 * pad) / W, + pay-h: bmm.mc.height / H, + pay-src: "measured", + over-x: _overflows-x(bmm.mu, bmm.mc, bw - 2 * pad), + pad-x: pad / W, pad-y: pad / H, + halign: "left", painted: true, paint-fill: t.skyll.to-hex()), + ) + let relations = (_rel(sid + ":label", sid + ":body", "labels", "horizontal", "gutter"),) + _emit(sid, "sidebar", "row", here().position().page, 1, objects, relations, row.fit, + (tuned: tuning.len() > 0)) + if debug { _debug-layer(objects, W, H) } + }) + }) +} diff --git a/packages/preview/xwysyy/0.5.0/src/slides.typ b/packages/preview/xwysyy/0.5.0/src/slides.typ new file mode 100644 index 0000000000..88234f0869 --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/src/slides.typ @@ -0,0 +1,359 @@ +// Slides: touying-based slide variants and the xwysyy-pre entry. + +#import "@preview/touying:0.8.0": * +#import "themes.typ": * +#import "elements.typ": * + +#let _auto-outline-title(title) = { + if title == auto { + context { + if text.lang == "zh" { [目录] } else { [Contents] } + } + } else { + title + } +} + +// Per-page manifest: every slide layout declares its kind and physical page, +// so the layout checker can compute telemetry coverage (which content pages +// carry no layout telemetry) and exempt title/section/end/image/outline pages. +#let _page-manifest(kind) = context [ + #metadata((schema: "xwysyy-page/v1", kind: kind, page: here().position().page)) +] + +// Checker-exempt page kinds (title / section / end / image / outline) are +// emitted by their own slide functions only: the public `xwysyy-slide` has no +// kind parameter, so a content page cannot spoof an exemption. +#let _kinded-slide(kind, title: auto, ..args) = { + touying-slide-wrapper(self => { + if title != auto { + self.store.title = title + } + // Open header (ported from the poster's section header): sans title in + // the theme color over a thin gradient rule fading to the right. The + // title shrinks to fit one line (floor 0.65×): the page's top margin is + // fixed, so a wrapped title would collide with the rule and the body. + // The applied scale is exported as `` telemetry; a title + // still wider than the header at the floor scale reports fits: false. + let header(self) = { + set align(top) + block( + width: 100% + 2em, + inset: (x: 1em, top: 1.1em), + { + context { + let avail = page.width - 2em.to-absolute() + let title-body = if self.store.title != none { + utils.call-or-display(self, self.store.title) + } else { + utils.display-current-heading(level: 2) + } + let styled(s) = text( + font: self.store.heading-font, + fill: self.colors.neutral-dark, + weight: "bold", + size: 1.45em * s, + title-body, + ) + let natural = measure(box(styled(1.0))).width + let scale = if natural > avail and natural > 0pt { + calc.max(avail / natural, 0.65) + } else { 1.0 } + let hsize = measure(box(styled(scale))) + // Vertical fit: top inset + title + gap + rule must stay inside + // the fixed top margin, or an explicitly wrapped / oversized + // title collides with the rule and the body. + let used = (1.1em + 0.65em + 0.12em).to-absolute() + hsize.height + [#metadata(( + schema: "xwysyy-header/v2", + page: here().position().page, + scale: scale, + fits: natural * scale <= avail + 0.01pt, + height: hsize.height.pt(), + fits_v: used <= 4.35em.to-absolute() + 0.01pt, + )) ] + block(styled(scale)) + } + v(0.65em, weak: true) + // Full-width track whose ink fades out completely by 92% — the + // tail dies before the page edge instead of running into it. + rect( + width: 100%, + height: 0.12em, + radius: (left: 0.06em), + fill: gradient.linear( + (self.colors.neutral-dark, 0%), + (self.colors.primary, 42%), + (self.colors.primary.transparentize(100%), 92%), + (self.colors.primary.transparentize(100%), 100%), + ), + ) + }, + ) + } + // Footer: page number in the bottom-right corner only. + let footer(self) = { + set align(bottom + right) + set text(fill: self.colors.neutral-dark, size: .9em) + block( + inset: (x: 0.5em, bottom: 0.4em), + context utils.slide-counter.display(), + ) + } + self = utils.merge-dicts( + self, + config-page( + header: header, + footer: footer, + ), + ) + // Prepend the page manifest to the slide body (works for both plain + // content and callback-style `self => ...` bodies). + let bodies = args.pos() + let raw-body = if bodies.len() > 0 { bodies.first() } else { [] } + let tagged = if type(raw-body) == function { + s => { _page-manifest(kind); raw-body(s) } + } else { + { _page-manifest(kind); raw-body } + } + touying-slide(self: self, ..args.named(), tagged, ..bodies.slice(1)) + }) +} + +#let xwysyy-slide(title: auto, ..args) = { + if "kind" in args.named() { + panic("xwysyy-slide: kind is not a parameter; outline pages use outline-slide, " + + "full-bleed pages use title-slide / new-section-slide / end-slide / image-slide") + } + _kinded-slide("content", title: title, ..args) +} + +#let title-slide(..args) = { + touying-slide-wrapper(self => { + self = utils.merge-dicts( + self, + config-page(margin: 0em), + ) + self.info = self.info + args.named() + let info = self.info + let body = { + _page-manifest("title") + set align(center + horizon) + line(length: 100%, stroke: self.colors.neutral-dark) + v(-0.9em) + block( + fill: self.colors.neutral-dark, + width: 100%, + inset: (y: 1.2em), + text(size: 2.2em, fill: self.colors.neutral-lightest, weight: "bold", info.title), + ) + if info.subtitle != none { + v(-0.9em) + block( + fill: self.colors.neutral-lighter, + width: 100%, + { + line(length: 100%, stroke: self.colors.neutral-dark) + v(-0.65em) + text(size: 1.6em, fill: self.colors.neutral-dark, weight: "bold", info.subtitle) + v(-0.65em) + line(length: 100%, stroke: self.colors.neutral-dark) + } + ) + } + set text(fill: self.colors.neutral-darkest) + v(1em) + if info.author != none { + block(text(size: 1.1em, info.author)) + } + if info.institution != none { + block(text(size: 0.9em, style: "italic", info.institution)) + } + if info.date != none { + block(utils.display-info-date(self)) + } + } + touying-slide(self: self, body) + }) +} + +#let new-section-slide(body) = { + touying-slide-wrapper(self => { + self = utils.merge-dicts( + self, + config-page( + margin: 0em, + ), + ) + let main-body = { + _page-manifest("section") + set align(center + horizon) + set text(size: 2em, fill: self.colors.neutral-dark, weight: "bold", style: "italic") + line(start: (17%, 0em), length: 83%, stroke: self.colors.neutral-dark) + v(-0.97em) + block( + fill: self.colors.neutral-lighter, + inset: (y: 0.42em), + width: 100%, + utils.display-current-heading(level: 1) + ) + v(-0.97em) + line(start: (-17%, 0em), length: 83%, stroke: self.colors.neutral-dark) + } + touying-slide(self: self, main-body) + }) +} + +#let end-slide(title: [Thank You!], body: none) = { + touying-slide-wrapper(self => { + self = utils.merge-dicts(self, config-page(margin: 2em)) + let main-body = { + _page-manifest("end") + set align(center + horizon) + text(fill: self.colors.neutral-dark, size: 3em, weight: "bold", title) + if body != none { + v(0.5em) + line(length: 20%, stroke: (thickness: 1.5pt, paint: self.colors.primary)) + v(0.5em) + text(size: 1.3em, fill: self.colors.neutral-dark.lighten(30%), body) + } + } + touying-slide(self: self, main-body) + }) +} + +#let outline-slide(chapters: auto, title: auto) = { + _kinded-slide("outline", title: _auto-outline-title(title))[ + #context { + let t = _theme-state.get() + let chs = if chapters == auto { + query(heading.where(level: 1)) + .filter(h => not h.has("label") or str(h.label) != "touying:hidden") + .map(h => h.body) + } else { + chapters + } + let two-col = chs.len() > 5 + let bsz = if two-col { 2em } else { 2.6em } + let tsz = if two-col { 1.4em } else { 2em } + let badge(n) = box( + width: bsz, height: bsz, fill: t.sea, radius: 50%, + align(center + horizon, text(fill: t.paper, weight: "bold", size: bsz * 0.58, n)) + ) + let items = chs.enumerate().map(((i, ch)) => grid( + columns: (auto, 1fr), + column-gutter: if two-col { 1em } else { 1.4em }, + align: (center + horizon, left + horizon), + badge(numbering("01", i + 1)), + text(size: tsz, weight: "semibold", ch), + )) + if two-col { + let mid = calc.ceil(items.len() / 2) + v(0.5em) + grid( + columns: (1fr, 1fr), + gutter: 1.5em, + stack(spacing: 1.6em, ..items.slice(0, mid)), + stack(spacing: 1.6em, ..items.slice(mid)), + ) + } else { + v(1em) + stack(spacing: 2.4em, ..items) + } + } + ] +} + +#let image-slide(body: none, img: none) = { + // An image slide without an image is a spoofed full-bleed exemption: the + // page would carry arbitrary body content while the checker skips it. + if img == none { + panic("image-slide: img is required; use xwysyy-slide for text-only pages") + } + touying-slide-wrapper(self => { + self = utils.merge-dicts( + self, + config-page( + background: img, + margin: (x: 0em, y: 1.4em), + ), + ) + set text(fill: self.colors.neutral-dark, size: 1.4em) + set image(width: 100%, height: auto) + touying-slide(self: self, { + _page-manifest("image") + align(left + bottom, + align( + center, + block( + fill: self.colors.neutral-lighter, + if body != none { + line(length: 100%, stroke: self.colors.neutral-dark) + v(-0.85em) + body + v(-0.85em) + line(length: 100%, stroke: self.colors.neutral-dark) + } + ) + ) + ) + }) + }) +} + +#let xwysyy-pre( + aspect-ratio: "16-9", + theme: "sky", + font: ("Times New Roman", "Noto Serif CJK SC"), + heading-font: ("Libertinus Sans", "Noto Sans CJK SC"), + code-font: ("Maple Mono", "Noto Sans Mono CJK SC"), + lang: "en", + ..args, + body, +) = { + let t = _resolve-theme(theme) + _theme-state.update(t) + set text( + font: font, + lang: lang, + size: 5.5mm, + weight: "semibold", + style: "normal", + ) + show: touying-slides.with( + config-page( + paper: "presentation-" + aspect-ratio, + margin: (top: 4.35em, x: 1em, bottom: 1.4em) + ), + config-common( + slide-fn: xwysyy-slide, + new-section-slide-fn: new-section-slide, + frozen-counters: (counter(figure), counter(math.equation)), + ), + config-colors( + primary: t.sky, + neutral-lighter: t.skyll, + neutral-lightest: t.paper, + neutral-dark: t.sea, + neutral-darkest: black, + ), + config-store( + title: none, + heading-font: heading-font, + ), + config-page( + fill: t.page-fill, + ), + ..args, + ) + + show: xwysyy-elements.with( + code-font: code-font, + t-sea: t.sea, + t-sky: t.sky, + t-skyll: t.skyll, + t-paper: t.paper, + ) + + body +} diff --git a/packages/preview/xwysyy/0.5.0/src/themes.typ b/packages/preview/xwysyy/0.5.0/src/themes.typ new file mode 100644 index 0000000000..dd1c300639 --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/src/themes.typ @@ -0,0 +1,95 @@ +// Theme color palettes, shared theme state, and inline highlight macros. + +#let themes = ( + sky: ( + sea: rgb("#3b60a0"), + sky: rgb("#bdd0f1"), + skyll: rgb("#f4f9ff"), + paper: rgb("#f5f6f8"), + page-fill: white, + ), + sunset: ( + sea: rgb("#970014"), + sky: rgb("#D8A6A2"), + skyll: rgb("#FFF8F6"), + paper: rgb("#f5f6f8"), + page-fill: rgb("#fffefd"), + ), + forest: ( + sea: rgb("#1f5d45"), + sky: rgb("#a8d5ba"), + skyll: rgb("#f5fbf7"), + paper: rgb("#f7faf8"), + page-fill: white, + ), + midnight: ( + sea: rgb("#1f2a44"), + sky: rgb("#8fa8d8"), + skyll: rgb("#f7faff"), + paper: rgb("#f7f8fb"), + page-fill: white, + ), + violet: ( + sea: rgb("#5a3e85"), + sky: rgb("#c7b7e8"), + skyll: rgb("#faf7ff"), + paper: rgb("#f8f6fb"), + page-fill: white, + ), + graphite: ( + sea: rgb("#31343a"), + sky: rgb("#b9c0c9"), + skyll: rgb("#f8f9fa"), + paper: rgb("#f7f7f5"), + page-fill: white, + ), +) + +#let _theme-required-fields = ( + "sea", + "sky", + "skyll", + "paper", + "page-fill", +) + +#let _resolve-theme(theme) = { + if type(theme) == str { + themes.at(theme) + } else if type(theme) == dictionary { + for field in _theme-required-fields { + if theme.at(field, default: auto) == auto { + panic("xwysyy-pre theme dictionary is missing field `" + field + "`") + } + } + theme + } else { + panic("xwysyy-pre theme must be a string name or a dictionary") + } +} + +// Default theme colors (sky) +#let sea = themes.sky.sea +#let sky = themes.sky.sky +#let skyll = themes.sky.skyll +#let paper = themes.sky.paper + +// Theme state for dynamic components +#let _theme-state = state("xwysyy-theme", themes.sky) + +// Bold recipe shared by `strong` and the colored bold macros: 1.1em + true +// bold weight, no stroke (stroking fills the CJK glyph counters and looks +// muddy). 0.03em tracking opens the run internally; 0.05em hair spacing on +// both sides keeps it off the neighbors (non-weak: weak spacing would swallow +// adjacent Latin word spaces); the 0.035em baseline drop re-centers the +// enlarged CJK glyphs optically. +#let _bold-run(body, fill: none) = { + set text(fill: fill) if fill != none + h(0.05em) + text(size: 1.1em, weight: 700, tracking: 0.03em, baseline: 0.035em, body) + h(0.05em) +} +#let red(body) = text(fill: rgb("#9c1d11"), body) +#let bred(body) = _bold-run(body, fill: rgb("#9c1d11")) +#let yellow(body) = text(fill: rgb("#d9ad20"), body) +#let byellow(body) = _bold-run(body, fill: rgb("#d9ad20")) diff --git a/packages/preview/xwysyy/0.5.0/template/main.typ b/packages/preview/xwysyy/0.5.0/template/main.typ new file mode 100644 index 0000000000..dcee4c28cc --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/template/main.typ @@ -0,0 +1,37 @@ +#import "@preview/xwysyy:0.5.0": * + +#show: xwysyy-pre.with( + theme: "sky", + config-info( + title: [xwysyy Starter Deck], + subtitle: [Academic slides in Typst], + author: " ", + date: datetime.today(), + institution: " ", + ), +) + +#title-slide() + +#outline-slide() + += Motivation + +== One Minute Setup + +Use `typst init @preview/xwysyy:0.5.0` to create this deck, then edit `main.typ`. + +#textbox( + [*Reusable components* + + `textbox`, #red[red highlights], #yellow[yellow highlights], tables, code blocks, and touying animations share one theme.], + + [*Theme control* + + Switch built-in themes with `theme: "sunset"` or pass a custom color dictionary directly.], +) + +#end-slide( + title: [Thank You!], + body: [Questions?], +) diff --git a/packages/preview/xwysyy/0.5.0/thumbnail.png b/packages/preview/xwysyy/0.5.0/thumbnail.png new file mode 100644 index 0000000000..3675e91a2a Binary files /dev/null and b/packages/preview/xwysyy/0.5.0/thumbnail.png differ diff --git a/packages/preview/xwysyy/0.5.0/typst.toml b/packages/preview/xwysyy/0.5.0/typst.toml new file mode 100644 index 0000000000..301f74dcfe --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/typst.toml @@ -0,0 +1,18 @@ +[package] +name = "xwysyy" +version = "0.5.0" +entrypoint = "xwysyy.typ" +authors = ["xwysyy"] +license = "MIT" +description = "Create academic slides with touying." +repository = "https://github.com/xwysyy/xwysyy-typst" +keywords = ["slides", "presentation", "academic", "touying", "theme"] +categories = ["presentation", "components"] +disciplines = ["computer-science"] +compiler = "0.15.0" +exclude = [] + +[template] +path = "template" +entrypoint = "main.typ" +thumbnail = "thumbnail.png" diff --git a/packages/preview/xwysyy/0.5.0/xwysyy.typ b/packages/preview/xwysyy/0.5.0/xwysyy.typ new file mode 100644 index 0000000000..876d101f2e --- /dev/null +++ b/packages/preview/xwysyy/0.5.0/xwysyy.typ @@ -0,0 +1,9 @@ +// xwysyy.typ — facade re-exporting every submodule. +// Users do `#import "xwysyy.typ": *` to pull in themes, slides, and layout components. + +#import "@preview/physica:0.9.8": * + +#import "src/themes.typ": * +#import "src/elements.typ": * +#import "src/slides.typ": * +#import "src/layout.typ": *