diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 8987306..de3deba 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -21,6 +21,7 @@ - [Metropolis](./themes/gallery/metropolis.md) - [University](./themes/gallery/university.md) - [Bipartite](./themes/gallery/bipartite.md) + - [Ratio](./themes/gallery/ratio.md) - [Build your own](./themes/your-own.md) - [Utilities](./utils/utils.md) - [Side by side](./utils/side-by-side.md) diff --git a/book/src/themes/gallery/ratio.md b/book/src/themes/gallery/ratio.md new file mode 100644 index 0000000..33f879f --- /dev/null +++ b/book/src/themes/gallery/ratio.md @@ -0,0 +1,152 @@ +# Ratio theme + +![ratio](ratio.png) + +This theme is inspired by the Singapore beamer theme. It has a more modern look, but still features Singapore's navigation sections and sub-section circles. + +Use it via + +```typ +{{#include ../../IMPORT.typ}} +#import themes.ratio: * + +#show: ratio-theme.with( + aspect-ratio: "16-9", + title: [Ratio theme], + abstract: [A theme about navigation and customization], + authors: (ratio-author("Theme Author", "Typst Community", "foo@bar.quux"),), + version: "1.0.0", + date: datetime(year: 2024, month: 4, day: 4), + keywords: ("foo", "bar"), + options: (:), +) +``` + +By default it already generates a cover page and sets some document attributes based on your settings. +You can disable this with `cover: false`. + +`ratio` uses polylux' section handling, the regular `#outline()` will not work +properly, use `#polylux-outline` instead. + +## Options for initialization + +The theme is highly customizable! If there's something you don't like or want to tweak, you probably can. + +### The essentials + +The essentials are separate keyword arguments to the `ratio-theme` call. + +- `aspect-ratio`: The aspect ratio (16-9 by default). +- `cover`: Wether to include the cover page. +- `title`: Presentation title content +- `abstract`: An abstract or subtitle for your work. Set to `none` to disable. +- `authors`: An array of objects made with the custom `author(name:, affiliation:, email: )` method. +- `date`: A datetime object, defaults to today. +- `version`: A document version to display. Something like "Draft" or "1.0". +- `keywords`: Output document keywords to set. + +### The `options` tweaks + +The `options` keyword argument is special, it takes a dictionary with any of the following: + +- `text`: Text style for the entire document. +- `register-headings`: Whether to register level 1 and 2 headings automatically as sections and subsections. +- `style-headings`: Enable/disable any styling applied to headings. +- `style-links`: Enable/disable any styling applied to links. +- `style-raw`: Enable/disable any styling applied to raw content. +- `hero`: What to show as the "hero" or background image on title slides. `auto` results in the theme's default background. +- `header`: What to display as the header ("navigation", "progress", content, or `none`). +- `footer`: What to display as the footer ("navigation", "progress", content, or `none`). +- `title-hero-color`: Tweak the background color for the coming title pages. +- `title-text`: Styling to apply to the entire title page's text. +- `title-heading-text`: Style of the title page's main title. +- `title-author-text`: Style of the title slide's author names. +- `title-affiliation-text`: Style of the title slide's affiliation entries. +- `title-abstract-text`: Style of the title slide's abstract. +- `title-version-text`: Style of the title slide's version text. +- `heading-texts`: The style to apply to heading text. + - Notice the `s`! It's an array of text styles for increasing heading depth! +- `heading-alignments`: The alignments to apply to headings. + - Notice the `s`! It's an array of alignments for increasing heading depth! +- `slide-box`: Arguments for the slide content's box container. +- `slide-grid`: Grid specification for the grid inside the box as arguments to `#grid()`. +- `slide-grid-cell`: Grid cell to use for main body content as arguments to `#grid.cell()`. +- `link-color`: The color to apply to the link anchor. +- `stroke-color`: The color to apply to strokes such as in tables. +- `fill-color`: The color to apply in fills such as in code blocks. +- `navigation-bar-color`: Navigation background color. +- `navigation-text`: Navigation bar text options for all text. +- `navigation-text-past`: Navigation bar text overrides for past sections. +- `navigation-text-current`: Navigation bar text overrides for the current section. +- `navigation-text-future`: Navigation bar text overrides for future sections. +- `navigation-shape-past`: Navigation bar shape for past subsections. +- `navigation-shape-current`: Navigation bar shape for current subsections. +- `navigation-shape-future`: Navigation bar shape for future subsections. +- `progress-bar-height`: Progress bar height. +- `progress-bar-color`: Progress bar background color. +- `progress-overlay-color`: Progress bar overlay color. +- `progress-text-color`: Progress bar text color. + +## Slide functions + +`ratio` provides the following custom slide functions: + +- `title-slide(title, authors, abstract, date, version, keywords, foreground, background, register-section)` with all keyword arguments: + - `title`: Presentation title content. Defaults to `none`. + - `authors`: An array of objects made with the custom `author(name:, affiliation:, email: )` method. Defaults to `none`. + - `abstract`: An abstract or subtitle for your work. Defaults to `none`. + - `date`: A datetime object, defaults to `none`. + - `version`: A document version to display on the date line. Something like "Draft" or "1.0". Defaults to `none`. + - `keywords`: Keywords to display on the date line. Defaults to (). + - `foreground`: Content to include in front of the default text content. + - `background`: Content to include behind the default text content. + - `hero`: What to show as the "hero" or background image on title slides. `auto` results in the theme's default background. +- `slide(title, header, footer)[body]` with a title keyword argument and body positional. + - `title`: Section title will be added as a heading, too. Mostly for compatibility purposes with other themes. + - `header`: Header content override. Default is `auto` which draws the header according to the theme options. Set it to `none` to disable for this slide. + - `footer`: Footer content override. Default is `auto` which draws the footer according to the theme options. Set it to `none` to disable for this slide. + - `box`: Arguments to the slide's box. Set it to `auto` which draws from theme options, `none` to disable the use of a box or some other arguments to the `box()` function. + - `align`: Slide content alignment. Set it to `auto` to draw from theme options, `none` to disable further alignment. + - `body`: Your content such that you can type `#slide[my foo is my bar]` +- `centered-slide(header, footer)[body]` same as `slide` with all content `center + horizon` +- `bare-slide[body]`: For now just an alias of `polylux-slide` + +See the [Customization](#customization) feature for ways to change these on the fly! + +## Additional features + +### Palette + +Do you like theme colors? Ratio includes a default palette to pull from in the `ratio-palette` variable. That should make it easier to style things! + +### Customization + +Ratio's key feature is customization. It stores all it's options in a `ratio-options` state variable. You probably won't interact with that variable directly, but its defaults are included as the `ratio-defaults` variable for you to inspect! + +```typ +// Careful, there are a lot of options! +#ratio-defaults +``` + +You can change the options in one of two ways: + +```typ +// Register: replaces the option values given in this dictionary completely. +// For example, this disables the link styling with the little anchor: +#ratio-register((style-links: false)) + +// Update: recursively updates any dictionaries (i.e. for `text()` related items). +// For example, this only replaces the title text's fill and leaves other options intact. +#ratio-update((title-text: (fill: ratio-palette.danger))) +``` + +You can put these overrides halfway through your document to change things on the fly! + +## Example code + +The image at the top is created by the following code: + +```typ +{{#include ../../IMPORT.typ}} +{{#include ratio.typ:3:}} +``` diff --git a/book/src/themes/gallery/ratio.typ b/book/src/themes/gallery/ratio.typ new file mode 100644 index 0000000..dfe7755 --- /dev/null +++ b/book/src/themes/gallery/ratio.typ @@ -0,0 +1,202 @@ +#import "../../../../polylux.typ": * + +#import themes.ratio: * + +#show: ratio-theme.with( + aspect-ratio: "16-9", + title: [Ratio theme], + abstract: [A theme about navigation and customization], + authors: (ratio-author("Theme Author", "Typst Community", "foo@bar.quux"),), + version: "1.0.0", + date: datetime(year: 2024, month: 4, day: 4), + keywords: ("navigation", "customization"), + options: (:), +) + +#centered-slide[ + = Welcome! +] + +#slide[ +== Theme setup +The title page was the result of: + +```typ +#import themes.ratio: * + +#show: ratio-theme.with( + cover: true, + aspect-ratio: "16-9", + title: [Ratio theme], + abstract: [A theme about navigation and customization], + authors: (ratio-author("Theme Author", "Typst Community", "foo@bar.quux"),), + version: "1.0.0", + date: datetime(year: 2024, month: 4, day: 4), + keywords: ("navigation", "customization"), + options: (:), +) +``` + +Which automatically generates a cover page if `cover` is set to `true`. +] + +#slide[ + = Navigation + Have you noticed the navigation bar at the top? + + You can press the main section titles, or just one of the subsection dots. + + Also, there's a progress bar at the bottom by default. + + == Subsections become dots + + The current section's dot is always "alight". + + == So there's two dots for "Navigation"! + + And they're both alight since we're on this page. + +] + +#slide[ + The last subsection is still active because we haven't registered a new section + yet! +] + +#slide[ + +#utils.register-section("Manual sections") +#utils.register-subsection("Manual subsection") + +== Adding manual subsections + +You can also manually register a new section using: + +```typ +#utils.register-section("Manual sections") +#utils.register-subsection("Manual subsection") +``` + +Which is what we did at the start of this slide's code. + +== Toggle headings registration +If you don't want headings to be registered by default, you can switch it +on/off: + +```typ +#ratio-update((register-headings: false)) +``` +] + +#slide[ += Customization + +#set text(size: 15pt) +We all know that layouts work all of the time 99% of the time. + +The `ratio-update` function updates option dictionaries *recursively* and thus +only updates what you specify. + +The `ratio-register` function replaces values *completely*. + +There's a handy `ratio-palette` variable with a pre-configured color palette, +but feel free to bring your own! + +```typ +// Only update the heading color, not it's size: +#ratio-update((title-text: (fill: ratio-palette.warning))) // <- Notice the palette! +// Next title slide will feature a green background. +#ratio-register((title-hero-color: color.hsl(green))) + +// Let's add some foreground and background content this time. We can place it anywhere! +#let fg = place(horizon + left, block(inset: 10%, width: 100%)[foreground.]) +#let bg = place( + horizon + left, + block(inset: 30%, width: 100%)[#text(weight: "bold")[background.]], +) +#title-slide(title: "Green", register-section: true, foreground: fg, background: bg) + +// This becomes...=> +``` +] +#ratio-update((title-text: (fill: ratio-palette.warning))) +#ratio-register((title-hero-color: color.hsl(green))) +#let fg = place(horizon + left, block(inset: 10%, width: 100%)[foreground.]) +#let bg = place( + horizon + left, + block(inset: 30%, width: 100%)[#text(weight: "bold")[background.]], +) +#title-slide(title: "Green", register-section: true, foreground: fg, background: bg) + +#slide[ += Slide layout + +== Alignment grid + +By default Ratio uses a 7x7 `grid` wrapped in a content `box` that fills the +page's space \ +between the header and footer. The grid has the following specifications: + +```typ +#let slide-grid = ( + rows: (auto, 1em, 3fr, auto, 5fr, 1em, auto), + columns: (auto, 2em, 1fr, auto, 1fr, 2.5em, auto), + gutter: 0pt, +) +``` + +Which achieves: +- edge content possible using placements in the first and last columns +- followed by padding around the main content +- content in the middle `(auto, auto)` cell +- "spring-loaded" positioning using the `fr` rows and columns + - The defaults roughly center the content on screen and push it slightly above the + horizon. +] + +#slide( + grid-children: (grid.cell(x: 6, rowspan: 7, fill: red, align: horizon)[cell]), +)[ +== Customizing the grid + +The default slide function `#slide` allows for customization of this grid using +the `grid-args` and `grid-cell` keyword arguments per slide. + +- `grid-args` fully customize the grid. + - `auto` means to use the grid settings from theme options. + - `none` means to disable the grid. + - anything else is treated as keyword arguments to `#grid` +- `grid-cell` the cell at which to put the body. + - `auto` means to put it at the cell as defined in theme options. + - `none` means to check if the body is an array: + - if it's an array, pass the array to `#grid` as the contents. + - if not, disable the grid functionality and use body as is. +- `grid-children` children to place on the grid. The bar on the right was achieved + with:\ + `grid-children: (grid.cell(x: 6, rowspan: 7, fill: red),)` +] + +#ratio-register(( + slide-grid: (columns: (5em, auto, 10em), rows: (1em, auto, 2em)), + slide-grid-cell: (x: 1, y: 1), +)) + +#slide[ +== Custom grid + +If you're not satisfied with the default grid, you can tweak things in the init +function, too. + +```typ +#show: ratio-theme.with( + //.., + options: ( + slide-grid: ( + columns: (5em, auto, 10em), + rows: (1em, auto, 2em), + ), + slide-grid-cell: (x: 1, y: 1), + ), +) +``` +] diff --git a/scripts/generate-previews.jl b/scripts/generate-previews.jl index 51e1331..6f99479 100644 --- a/scripts/generate-previews.jl +++ b/scripts/generate-previews.jl @@ -38,14 +38,14 @@ function montage(imgs, annotation) annotation end - + a = min(max(ceil(Int, sqrt(n)), 3), n) b = ceil(Int, n / a) b, a = minmax(a, b) @assert a * b >= n idcs = CartesianIndices((1:a, 1:b)) dims = size(first(imgs)) .* (b, a) .* .6 |> reverse - + plt = plot( size = dims, background = :lightgray, @@ -147,6 +147,7 @@ generate_previews([ typ2png(path = gallery, file = "metropolis"), typ2png(path = gallery, file = "university"), typ2png(path = gallery, file = "bipartite"), + typ2png(path = gallery, file = "ratio"), typ2png(path = utils, file = "side-by-side"), typ2png(path = utils, file = "side-by-side-kwargs"), typ2png(path = utils, file = "fill-remaining"), diff --git a/themes/ratio.typ b/themes/ratio.typ new file mode 100644 index 0000000..5ab7d4a --- /dev/null +++ b/themes/ratio.typ @@ -0,0 +1,812 @@ +// Ratio theme +// +// A highly customizable theme inspired by old Beamer theme Singapore with +// the section names at the top. +// +// The theme's initialization function is at the bottom of this file. + +#import "../logic.typ" +#import "../utils/utils.typ" + +// GLOBAL HELPERS + +// Ratio color palette for easy styling. +#let ratio-palette = ( + primary-900: color.hsl(rgb("#1f4ac3")), + primary-800: color.hsl(rgb("#2c57ce")), + primary-700: color.hsl(rgb("#3963d9")), + primary-600: color.hsl(rgb("#4370ec")), + primary-500: color.hsl(rgb("#4d7cfe")), + primary-400: color.hsl(rgb("#6890fe")), + primary-300: color.hsl(rgb("#82a3fe")), + primary-200: color.hsl(rgb("#82a3fe")), + primary-100: color.hsl(rgb("#a6beff")), + primary-50: color.hsl(rgb("#eaefff")), + secondary-900: color.hsl(rgb("#0f0f25")), + secondary-800: color.hsl(rgb("#171731")), + secondary-700: color.hsl(rgb("#1e1e3d")), + secondary-600: color.hsl(rgb("#232345")), + secondary-500: color.hsl(rgb("#28284d")), + secondary-400: color.hsl(rgb("#38385b")), + secondary-300: color.hsl(rgb("#484868")), + secondary-200: color.hsl(rgb("#848499")), + secondary-100: color.hsl(rgb("#bfbfca")), + secondary-50: color.hsl(rgb("#e5e5ea")), + contrast: white, + success: color.hsl(rgb("#8bc34a")), + warning: color.hsl(rgb("#ff9800")), + danger: color.hsl(rgb("#f44336")), + error: color.hsl(rgb("#f44336")), + info: color.hsl(rgb("#4d7cfe")), + cat-0: color.hsl(rgb("#e58606")), + cat-1: color.hsl(rgb("#5d69b1")), + cat-2: color.hsl(rgb("#52bca3")), + cat-3: color.hsl(rgb("#99c945")), + cat-4: color.hsl(rgb("#cc61b0")), + cat-5: color.hsl(rgb("#24796c")), + cat-6: color.hsl(rgb("#daa51b")), + cat-7: color.hsl(rgb("#2f8ac4")), + cat-8: color.hsl(rgb("#764e9f")), + cat-9: color.hsl(rgb("#ed645a")), + cat-10: color.hsl(rgb("#a5aa99")), + transparent: color.hsl(rgb(0, 0, 0, 0)), +) + +// Create a Ratio theme author entry. +#let ratio-author(name, affiliation, email) = { (name: name, affiliation: affiliation, email: email) } + +// Ratio default options. +#let ratio-defaults = ( + // Presentation aspect ratio. + aspect-ratio: "16-9", + // Presentation title. + title: [Presentation title], + // An abstract for your work. Can be omitted if you don't have one. + abstract: lorem(30), + // Presentation authors/presenters. + authors: ( + ratio-author("Jane Doe", "Foo Ltd.", "jane.doe@foo.ltd"), + ratio-author("Foo Bar", "Quux Co.", "foo.bar@quux.co"), + ), + // Date that will be displayed on cover page. + // The value needs to be of the 'datetime' type. + // More info: https://typst.app/docs/reference/foundations/datetime/ + // Example: datetime(year: 2024, month: 03, day: 17) + date: datetime.today(), + // Document keywords to set. + keywords: (), + // The version of your work. + version: "Draft", + // Default font settings. + text: (font: ("Noto Sans", "Open Sans"), size: 18pt), + // Whether to register headings as sections and subsections. + register-headings: true, + // Whether to apply some heading styling. + style-headings: true, + // Whether to apply the custom link style. + style-links: true, + // Whether to apply some raw styling. + style-raw: true, + // What to show as the "hero" or background image on title slides. `auto` means + // the theme's default background (auto, content, none). + hero: auto, + // What to show in the header ("navigation", "progress", content, none). + header: "navigation", + // What to show in the footer ("navigation", "progress", content, none). + footer: "progress", + // Title background color. + title-hero-color: ratio-palette.secondary-800, + // Title text style. + title-text: ( + font: ("Cantarell", "Noto Sans", "Open Sans"), + size: 20pt, + fill: ratio-palette.contrast, + ), + // Title text heading overrides. + title-heading-text: (size: 3em, weight: "bold"), + // Title author text heading overrides. + title-author-text: (:), + // Title affiliation text overrides. + title-affiliation-text: (size: 0.8em, weight: "light"), + // Title abstract text overrides. + title-abstract-text: (:), + // Title date and version text override. + title-version-text: (size: 0.8em, weight: "light"), + // Title vertical spacing. + title-gutter: 5%, + // Common heading text style. + heading-text: (font: ("Cantarell", "Noto Sans", "Open Sans"), hyphenate: false), + // Heading text style overrides in order of heading depth. + heading-texts: ((fill: ratio-palette.secondary-800),), + // Heading alignments in order of heading depth. + heading-alignments: (left,), + // Slide content box options. + slide-box: (width: 100%, height: 100%, clip: true), + // Content slide alignment. + slide-grid: ( + rows: (auto, 1em, 3fr, auto, 5fr, 1em, auto), + columns: (auto, 2em, 1fr, auto, 1fr, 2em, auto), + gutter: 0pt, + ), + // Slide grid cell. + slide-grid-cell: (x: 3, y: 3), + // Color for external link anchors. + link-color: ratio-palette.primary-500, + // Stroke color for tables and such. + stroke-color: ratio-palette.secondary-100, + // Fill color for code blocks and such. + fill-color: ratio-palette.secondary-50, + // Navigation background color. + navigation-bar-color: ratio-palette.secondary-50, + // Navigation text options for all text. + navigation-text: (fill: ratio-palette.secondary-200, size: 0.5em), + // Navigation text overrides for past sections. + navigation-text-past: (:), + // Navigation text overrides for the current section. + navigation-text-current: (weight: "bold"), + // Navigation text overrides for future sections. + navigation-text-future: (:), + // Navigation shape for past subsections. + navigation-shape-past: box(height: 3.8pt, circle( + radius: 1.7pt, + fill: ratio-palette.secondary-100, + stroke: 0.7pt + ratio-palette.secondary-100, + )), + // Navigation shape for current subsections. + navigation-shape-current: box(height: 3.8pt, circle( + radius: 1.7pt, + fill: ratio-palette.primary-500, + stroke: 0.7pt + ratio-palette.primary-500, + )), + // Navigation shape for future subsections. + navigation-shape-future: box( + height: 3.8pt, + circle(radius: 1.7pt, stroke: 0.7pt + ratio-palette.secondary-100), + ), + // Progress bar height. + progress-bar-height: 5pt, + // Progress bar background color. + progress-bar-color: ratio-palette.secondary-50, + // Progress bar overlay color. + progress-overlay-color: ratio-palette.secondary-100, + // Progress bar text color. + progress-text-color: ratio-palette.secondary-200, +) + +// Variable to hold the options state. +#let ratio-options = state("ratio-options", ratio-defaults) + +// Register new options or replace existing keys. +#let ratio-register(options) = { + ratio-options.update(s => { + s = s + options + s + }) +} + +// Update options by replacing values and updating dictionaries. +#let ratio-update(options) = { + ratio-options.update(s => utils.update-dict(s, options)) +} + +// Draw a tiny anchor on the top right of the body text. +#let ratio-anchor(body, color: ratio-defaults.link-color) = { + box[#body#h(0.05em)#super(box(height: 0.7em, circle(radius: 0.15em, stroke: 0.08em + color)))] +} + +// TITLE SLIDE + +// Ratio custom background image. +#let ratio-hero(fill: ratio-defaults.title-hero-color) = { + // Build the background. + place(block(width: 100%, height: 100%, fill: fill)) + // The left triangle. + place(left + top, polygon( + fill: fill.lighten(20%).transparentize(50%), + (0%, 100%), + (25%, 80%), + (0%, 70%), + )) + // The bottom triangle. + place(left + top, polygon( + fill: fill.lighten(20%).transparentize(20%), + (0%, 100%), + (25%, 80%), + (75%, 100%), + )) + // The large one on the right. + place(left + top, polygon( + fill: fill.lighten(20%).transparentize(85%), + (0%, 100%), + (100%, 100%), + (100%, 20%), + )) +} + +// Ratio title text content. Draws only from defaults, fully customizable. +#let ratio-title-content( + title: ratio-defaults.title, + authors: ratio-defaults.authors, + abstract: ratio-defaults.abstract, + date: ratio-defaults.date, + version: ratio-defaults.version, + keywords: ratio-defaults.keywords, + title-text: ratio-defaults.title-text, + heading-text: ratio-defaults.title-heading-text, + author-text: ratio-defaults.title-author-text, + affiliation-text: ratio-defaults.title-affiliation-text, + abstract-text: ratio-defaults.title-abstract-text, + version-text: ratio-defaults.title-version-text, + gutter: ratio-defaults.title-gutter, + register-section: false, +) = { + set text(..title-text) + + let rows = () + + if title != none { + rows.push(text(..heading-text)[#title]) + if register-section { + utils.register-section(title) + } + } + + if authors != none and authors.len() > 0 { + for author in utils.as-array(authors) { + let name = author.at("name", default: none) + let email = author.at("email", default: none) + let affiliation = author.at("affiliation", default: none) + let content = { + let author-text = text(..author-text)[#name] + if email == none { + author-text + } else { + link("mailto:" + email)[#author-text] + } + + if affiliation != none { + v(0.3em, weak: true) + text(..affiliation-text)[#affiliation] + } + } + rows.push(content) + } + } + + if abstract != none { + rows.push([ + #set text(..abstract-text) + #abstract + ]) + } + + let date-line = () + if date != none { + date-line.push(date.display()) + } + if version != none { + date-line.push(version) + } + if keywords != none and keywords.len() > 0 { + let keywords = utils.as-array(keywords) + date-line.push(keywords.join(", ")) + } + + if date-line.len() > 0 { + let sep = [ + #h(1.6pt) + | + #h(1.6pt) + ] + + rows.push([ + #set text(..version-text) + #date-line.join(sep) + ]) + } + + grid(columns: (auto), gutter: gutter, ..rows) +} + +// Ratio style title slide. +// Draws options from context that are not in the parameter list. +#let title-slide( + // Slide title. + title: none, + // Document authors/presenters. + authors: none, + // Presentation abstract or subtitle. + abstract: none, + // Presentation date. Set to none to hide. + date: none, + // Presentation version. Set to none to hide. + version: none, + // Presentation keywords. Set to none to hide. + keywords: none, + // Foreground content to show (in front of regular title page content). + foreground: none, + // Background content to show (behind regular title page content). + background: none, + // What to show as the "hero" or background image on title slides. + // auto means the theme's default background (auto, content, none). + hero: auto, + // Whether to register this slide title as a section. + register-section: false, +) = { + let content = context { + let options = ratio-options.get() + + if hero == auto { + let fill = options.title-hero-color + if fill != none { + ratio-hero(fill: fill) + } + } else { + place(top + left, block(width: 100%, height: 100%, hero)) + } + + if background != none { + background + } + + place(left + horizon, block(width: 100%, inset: 15%, ratio-title-content( + title: title, + authors: authors, + abstract: abstract, + date: date, + version: version, + keywords: keywords, + title-text: options.title-text, + heading-text: options.title-heading-text, + author-text: options.title-author-text, + affiliation-text: options.title-affiliation-text, + abstract-text: options.title-abstract-text, + version-text: options.title-version-text, + gutter: options.title-gutter, + ))) + + if foreground != none { + foreground + } + } + logic.polylux-slide(content) +} + +// CONTENT SLIDE HELPERS + +// Ratio style section navigation bar. Draws everything from options. +#let ratio-navigation() = { + locate( + loc => { + // Get the variables at this stage or final. + let options = ratio-options.at(loc) + let page = loc.page() + let secs = utils.sections-state.final() + let subs = utils.subsections-state.final() + + set text(..options.navigation-text) + + // Precalculate when sections and subsections end. + let sec_ends = { + if secs.len() > 1 { + secs.slice(1).map(s => s.loc.page()) + } else { + () + } + } + sec_ends.push(none) + + let sub_ends = { + subs.enumerate().map(((idx, subs)) => { + let ends = if subs.len() > 1 { + subs.slice(1).map(s => s.loc.page()) + } else { + () + } + // Last one ends at next section. + ends.push(sec_ends.at(idx, default: none)) + ends + }) + } + + // The main headings. + let sec_displays = secs.zip(sec_ends).map(((sec, end)) => { + link(sec.loc)[ + #if page < sec.loc.page() { + text(..options.navigation-text-future, sec.body) + } else { + if page == sec.loc.page() or (end != none and page < end) { + text(..options.navigation-text-current, sec.body) + } else { + text(..options.navigation-text-past, sec.body) + } + } + ] + }) + + // Subsection shapes. + let sub_displays = subs.zip(sub_ends).map( + ((subs, ends)) => { + let columns = if subs.len() > 0 { + subs.len() + } else { + 1 + } + pad( + x: .5em, + grid(columns: columns, gutter: .5em, ..subs.zip(ends).map(((sub, end)) => { + link(sub.loc)[ + #if page < sub.loc.page() { + options.navigation-shape-future + } else { + if page == sub.loc.page() or (end != none and page < end) { + options.navigation-shape-current + } else { + options.navigation-shape-past + } + } + ] + })), + ) + }, + ) + + // Combine into a block that fills the header. + block(fill: options.navigation-bar-color, width: 100%, align(horizon, { + pad(x: 0.8em, y: 0.4em, grid( + columns: range(sec_displays.len()).map(_ => 1fr), + gutter: .4em, + ..sec_displays, + ..sub_displays, + )) + })) + }, + ) +} + +// Ratio progress bar. Draws everything from options. +#let ratio-progress() = { + locate(loc => { + let options = ratio-options.at(loc) + let current = loc.page() + let total = counter(page).final().first() + block( + fill: options.progress-bar-color, + width: 100%, + height: options.progress-bar-height, + place(left + horizon, utils.polylux-progress(ratio => block( + fill: options.progress-overlay-color, + width: ratio * 100%, + height: 100%, + ))), + ) + }) +} + +// Ratio header or footer bar helper. +#let ratio-bar(kind) = { + if kind == "navigation" { + ratio-navigation() + } else if kind == "progress" { + ratio-progress() + } else if kind == none { + [] + } else { + kind + } +} + +// Ratio header helper. +#let ratio-header() = context { ratio-bar(ratio-options.get().at("header", +default: none)) } + +// Ratio footer helper. +#let ratio-footer() = context { ratio-bar(ratio-options.get().at("footer", +default: none)) } + +// Ratio content box helper. Wraps it in a box+grid combination. +#let ratio-content( + box-args: auto, + grid-args: auto, + grid-cell: auto, + grid-children: auto, + body, +) = { + context { + let options = ratio-options.get() + + let grid-children = if grid-children == auto { + options.grid-children + } else if grid-children == none { + () + } else { + utils.as-array(grid-children) + } + + let g = if grid-args == auto { + grid.with(..options.slide-grid) + } else if grid-args == none { + none + } else { + grid.with(..grid-args) + } + + let body = if g == none { + body + } else { + if grid-cell == auto { + g(grid.cell(..options.slide-grid-cell, body), ..grid-children) + } else if grid-cell == none { + if type(body) == array { + g(..body, ..grid-children) + } else { + body + } + } else { + g(grid.cell(..grid-cell, body), ..grid-children) + } + } + + let body = if box-args == auto { + box(..options.slide-box, body) + } else if box-args == none { + body + } else { + box(..box-args, body) + } + body + } +} + +// CONTENT SLIDES + +// Ratio style slide. +#let slide( + title: none, + depth: 1, + header: auto, + footer: auto, + box-args: auto, + grid-args: auto, + grid-cell: auto, + grid-children: (), + body, +) = { + let inner = { + if title != none { + heading(depth: depth, box(width: 100%, align(center, title))) + } + body + } + let content = ratio-content( + box-args: box-args, + grid-args: grid-args, + grid-cell: grid-cell, + grid-children: grid-children, + inner, + ) + let header = if header == auto { + ratio-header() + } else { + header + } + let footer = if footer == auto { + ratio-footer() + } else { + footer + } + logic.polylux-slide(grid( + columns: 1, + gutter: 0pt, + rows: (auto, 1fr, auto), + ..(header, content, footer), + )) +} + +// Ratio style centered slide. +#let centered-slide( + title: none, + depth: 1, + header: auto, + footer: auto, + box-args: auto, + body, +) = { + slide( + title: title, + depth: depth, + header: header, + footer: footer, + box-args: box-args, + grid-args: none, + align(horizon + center, box(body)), + ) +} + +// Ratio style bare bones slide. +#let bare-slide = logic.polylux-slide + +// THEME + +// The Ratio theme function that sets up all styling and show rules once. +// Any provided options update the defaults by recursively updating the +// options dictionary. I.e. setting: `options: (title-text: (fill: red))` +// Only changes the title text's fill to red and maintains other options. +// You can view all defaults in the `ratio-defaults` variable. +#let ratio-theme( + // Presentation aspect ratio. + aspect-ratio: "16-9", + // Whether to include the default cover page. + cover: true, + // Presentation title. + title: [Presentation title], + // An abstract for your work. Can be omitted if you don't have one. + abstract: lorem(30), + // Presentation authors/presenters. + authors: ( + ratio-author("Jane Doe", "Foo Ltd.", "jane.doe@foo.ltd"), + ratio-author("Foo Bar", "Quux Co.", "foo.bar@quux.co"), + ), + // Date that will be displayed on cover page. + // The value needs to be of the 'datetime' type. + // More info: https://typst.app/docs/reference/foundations/datetime/ + // Example: datetime(year: 2024, month: 03, day: 17) + date: datetime.today(), + // Document keywords to set. + keywords: (), + // The version of your work. + version: "Draft", + // Ratio theme options. + options: (:), + // Presentation contents. + body, +) = { + let keywords = { + if keywords == none { + () + } else { + keywords + } + } + // Set document properties. + set document( + title: title, + author: authors.first().name, + date: date, + keywords: keywords, + ) + + set page( + paper: "presentation-" + aspect-ratio, + margin: 0em, + header: none, + footer: none, + ) + + // Update all options. + let options = options + ( + aspect-ratio: aspect-ratio, + title: title, + abstract: abstract, + authors: authors, + date: date, + keywords: keywords, + version: version, + ) + ratio-update(options) + + // Text setup. + set par(leading: 0.7em, justify: true, linebreaks: "optimized") + show par: set block(spacing: 1.35em) + + // Any text + show: it => context { + set text(..ratio-options.get().text) + it + } + + // Heading setup. + show heading: it => context { + let options = ratio-options.get() + if options.register-headings and logic.subslide.get().first() == 1 { + // Register sections and subsections. + if it.depth == 1 { + utils.register-section(it.body) + } else if it.depth == 2 { + utils.register-subsection(it.body) + } + } + if options.style-headings { + let depth = it.depth - 1 + + let alignments = utils.as-array(options.heading-alignments) + let value = if depth < alignments.len() { + alignments.at(depth) + } else if alignments.len() > 0 { + alignments.last() + } else { + none + } + set align(value) + + let texts = utils.as-array(options.heading-texts) + let style = if depth < texts.len() { + texts.at(depth) + } else if texts.len() > 0 { + texts.last() + } else { + (:) + } + // Do not hyphenate headings. + text(..options.heading-text, ..style)[#it] + } else { + it + } + } + + // Style links if set. + show link: it => context { + let options = ratio-options.get() + if options.style-links { + // Don't style for internal links. + if type(it.dest) == label or type(it.dest) == location { + return it + } + let color = options.at("link-color", default: ratio-palette.primary-500) + ratio-anchor(it) + } else { + it + } + } + + // Set raw font to Fira Code if available. + show raw.where(block: true): it => context { + let options = ratio-options.get() + if options.style-raw { + set text(font: "Fira Code") + block( + inset: (x: .3em), + fill: options.fill-color.lighten(25%), + outset: (y: .5em), + radius: .15em, + it, + ) + } else { + it + } + } + + show raw.where(block: false): it => context { + let options = ratio-options.get() + if options.style-raw { + set text(font: "Fira Code") + box( + fill: options.fill-color, + inset: (x: .3em), + outset: (y: .3em), + radius: .15em, + it, + ) + } else { + it + } + } + + // Title slide if set. + if cover { + title-slide( + title: title, + authors: authors, + abstract: abstract, + date: date, + version: version, + keywords: keywords, + // Pick the option without another context. + hero: options.at("hero", default: ratio-defaults.hero), + register-section: false, // Don't register the cover. + ) + } + + // Presentation contents. + body +} diff --git a/themes/themes.typ b/themes/themes.typ index c3ef0ed..ef42539 100644 --- a/themes/themes.typ +++ b/themes/themes.typ @@ -3,3 +3,4 @@ #import "bipartite.typ" #import "university.typ" #import "metropolis.typ" +#import "ratio.typ" diff --git a/utils/utils.typ b/utils/utils.typ index ba2690b..65c73e4 100644 --- a/utils/utils.typ +++ b/utils/utils.typ @@ -5,14 +5,36 @@ // SECTIONS #let sections-state = state("polylux-sections", ()) -#let register-section(name) = locate( loc => { - sections-state.update(sections => { - sections.push((body: name, loc: loc)) - sections +#let subsections-state = state("polylux-subsections", ()) + +#let register-section(name) = locate(loc => { + sections-state.update(s => { + s.push((body: name, loc: loc)) + s + }) + subsections-state.update(s => { + s.push(()) + s + }) +}) + +#let register-subsection(name) = locate(loc => { + sections-state.update(s => { + if s.len() == 0 { + s.push((body: [], loc: loc)) + } + s + }) + subsections-state.update(s => { + if s.len() == 0 { + s.push(()) + } + s.last().push((body: name, loc: loc)) + s }) }) -#let current-section = locate( loc => { +#let current-section = locate(loc => { let sections = sections-state.at(loc) if sections.len() > 0 { sections.last().body @@ -21,25 +43,39 @@ } }) -#let polylux-outline(enum-args: (:), padding: 0pt) = locate( loc => { +#let current-subsection = locate(loc => { + let subsections = subsections-state.at(loc) + if subsections.len() > 0 { + let arr = subsections.last() + if arr.len() > 0 { + arr.last().body + } else { + [] + } + } else { + [] + } +}) + +#let polylux-outline(enum-args: (:), padding: 0pt) = locate(loc => { let sections = sections-state.final(loc) pad(padding, enum( ..enum-args, - ..sections.map(section => link(section.loc, section.body)) + ..sections.map(section => link(section.loc, section.body)), )) }) - // PROGRESS -#let polylux-progress(ratio-to-content) = locate( loc => { - let ratio = logic.logical-slide.at(loc).first() / logic.logical-slide.final(loc).first() - ratio-to-content(ratio) -}) +#let polylux-progress(ratio-to-content) = locate( + loc => { + let ratio = logic.logical-slide.at(loc).first() / logic.logical-slide.final(loc).first() + ratio-to-content(ratio) + }, +) #let last-slide-number = locate(loc => logic.logical-slide.final(loc).first()) - // HEIGHT FITTING #let _size-to-pt(size, styles, container-dimension) = { @@ -78,65 +114,93 @@ hidden#after-label ] - locate(loc => { - let before = query(selector(before-label).before(loc), loc) - let before-pos = before.last().location().position() - let after = query(selector(after-label).before(loc), loc) - let after-pos = after.last().location().position() - - let available-height = after-pos.y - before-pos.y - - style(styles => { - layout(container-size => { - // Helper function to more easily grab absolute units - let get-pts(body, w-or-h) = { - let dim = if w-or-h == "w" {container-size.width} else {container-size.height} - _size-to-pt(body, styles, dim) - } - - // 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, styles - ) - - // post-scaling width - let mutable-width = width - if width == none { - mutable-width = container-size.width - } - mutable-width = get-pts(mutable-width, "w") - - let size = measure(boxed-content, styles) - let h-ratio = available-height / size.height - let w-ratio = mutable-width / size.width - let ratio = calc.min(h-ratio, w-ratio) * 100% - - let new-width = size.width * ratio - v(-available-height) - // If not boxed, the content can overflow to the next page even though it will fit. - // This is because scale doesn't update the layout information. - // Boxing in a container without clipping will inform typst that content - // will indeed fit in the remaining space - box( - width: new-width, - height: available-height, - scale(x: ratio, y: ratio, origin: top + left, boxed-content) - ) - }) - }) - }) + locate( + loc => { + let before = query(selector(before-label).before(loc), loc) + let before-pos = before.last().location().position() + let after = query(selector(after-label).before(loc), loc) + let after-pos = after.last().location().position() + + let available-height = after-pos.y - before-pos.y + + style( + styles => { + layout( + container-size => { + // Helper function to more easily grab absolute units + let get-pts(body, w-or-h) = { + let dim = if w-or-h == "w" { container-size.width } else { container-size.height } + _size-to-pt(body, styles, dim) + } + + // 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, styles) + + // post-scaling width + let mutable-width = width + if width == none { + mutable-width = container-size.width + } + mutable-width = get-pts(mutable-width, "w") + + let size = measure(boxed-content, styles) + let h-ratio = available-height / size.height + let w-ratio = mutable-width / size.width + let ratio = calc.min(h-ratio, w-ratio) * 100% + + let new-width = size.width * ratio + v(-available-height) + // If not boxed, the content can overflow to the next page even though it will fit. + // This is because scale doesn't update the layout information. + // Boxing in a container without clipping will inform typst that content + // will indeed fit in the remaining space + box( + width: new-width, + height: available-height, + scale(x: ratio, y: ratio, origin: top + left, boxed-content), + ) + }, + ) + }, + ) + }, + ) } // SIDE BY SIDE #let side-by-side(columns: none, gutter: 1em, ..bodies) = { let bodies = bodies.pos() - let columns = if columns == none { (1fr,) * bodies.len() } else { columns } + let columns = if columns == none { (1fr,) * bodies.len() } else { columns } if columns.len() != bodies.len() { panic("number of columns must match number of content arguments") } grid(columns: columns, gutter: gutter, ..bodies) } + +// HELPERS + +// Guaranteed array helper. Users often supply a single argument to something that +// should be an array. +#let as-array(value) = { + if type(value) == array { + value + } else { + (value,) + } +} + +// Recursively update a dictionary. +#let update-dict(dict, update) = { + for ((key, value)) in update.pairs() { + if type(value) == dictionary { + dict.insert(key, update-dict(dict.at(key, default: (:)), value)) + } else { + dict.insert(key, value) + } + } + dict +}