├── .gitignore ├── .typos.toml ├── src ├── styling.typ ├── utils │ ├── text-queries.typ │ ├── encode.typ │ └── html.typ ├── inspect.typ ├── lib.typ ├── parse.typ ├── layout.typ └── styles │ ├── hint.typ │ ├── thmbox.typ │ └── boxy.typ ├── typst.toml ├── .github └── ISSUE_TEMPLATE │ ├── help_needed.md │ ├── bug_report.md │ └── feature_request.md ├── LICENSE ├── CHANGELOG.md ├── justfile ├── README.typ └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.pdf 2 | *.svg 3 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | extend-ignore-words-re = ['typ'] 3 | -------------------------------------------------------------------------------- /src/styling.typ: -------------------------------------------------------------------------------- 1 | /* 2 | * This file mainly exports functionality needed when writing your own styling function. 3 | */ 4 | #let divide() = metadata("THIS-IS-METADATA-TO-BE-REPLACED-BY-CUSTOM-STYLING-PER-STYLING-FUNCTION") 5 | 6 | #let dividers-as(divider-element) = ( 7 | document => { 8 | show metadata: it => if it == divide() { 9 | divider-element 10 | } else { 11 | it 12 | } 13 | document 14 | } 15 | ) 16 | -------------------------------------------------------------------------------- /src/utils/text-queries.typ: -------------------------------------------------------------------------------- 1 | #let lang-is-left-to-right() = { 2 | let rtl-langs = ( 3 | "ar", 4 | "dv", 5 | "fa", 6 | "he", 7 | "ks", 8 | "pa", 9 | "ps", 10 | "sd", 11 | "ug", 12 | "ur", 13 | "yi", 14 | ) 15 | text.lang not in rtl-langs 16 | } 17 | 18 | #let text-is-left-to-right() = if text.dir == auto { 19 | lang-is-left-to-right() 20 | } else { 21 | text.dir == ltr 22 | } 23 | 24 | #let real-text-direction() = if text-is-left-to-right() { ltr } else { rtl } 25 | 26 | -------------------------------------------------------------------------------- /typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "frame-it" 3 | version = "1.2.0" 4 | entrypoint = "src/lib.typ" 5 | authors = ["Marc Thieme"] 6 | repository = "https://github.com/marc-thieme/frame-it" 7 | license = "MIT" 8 | keywords = ["theorems", "frames", "custom", "style", "styling", "math", "papers", "notes", "lectures", "scientific writing", "html"] 9 | categories = ["components", "layout", "report"] 10 | description = "Beautiful, flexible, and integrated. Display custom frames for theorems, environments, and more. Attractive visuals with syntax that blends seamlessly into the source." 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help_needed.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: I don't know how to do something 3 | about: Ask how to solve a specific problem 4 | title: "[ Help ] …" 5 | labels: '' 6 | assignees: marc-thieme 7 | 8 | --- 9 | 10 | **Code sample + explanation of what you would try to achieve** 11 | … 12 | 13 | **What have you tried so far?** 14 | … 15 | 16 | **How would you have expected it to work?** 17 | Feel free to provide ways you tried but didn't work or how you feel would be natural. 18 | 19 | If you know of other projects where the same thing is possible, I would be interested to know how they do it :) 20 | 21 | --- 22 | 23 | - Feel free to delete sections which don't apply 24 | - Feel free to be brief. One sentence might suffice. 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[ BUG ] …" 5 | labels: bug 6 | assignees: marc-thieme 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Minimal reproducible example** 14 | - Please provide a small but sufficient code snippet. 15 | - Please also include the `import`–line and your frame–definitions. 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | 26 | --- 27 | 28 | - Feel free to delete sections which don't apply 29 | - Feel free to be brief. One sentence might suffice. 30 | -------------------------------------------------------------------------------- /src/inspect.typ: -------------------------------------------------------------------------------- 1 | #let is-frame(figure) = { 2 | import "utils/encode.typ": code-has-info-attached 3 | ( 4 | "caption" in figure.fields() 5 | and "body" in figure.caption.fields() 6 | and code-has-info-attached(figure.caption.body) 7 | ) 8 | } 9 | 10 | #let lookup-frame-info(figure) = { 11 | import "utils/encode.typ": retrieve-info-from-code 12 | 13 | assert( 14 | is-frame(figure), 15 | message: "You can only provide figures to `lookup-frame-info`" 16 | + "which represent frame-it–frames", 17 | ) 18 | 19 | let ( 20 | title, 21 | tags, 22 | body, 23 | supplement, 24 | custom-arg, 25 | style, 26 | ) = retrieve-info-from-code(figure.caption.body) 27 | 28 | ( 29 | title: title, 30 | tags: tags, 31 | body: body, 32 | supplement: supplement, 33 | color: custom-arg, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[ Feature ] …" 5 | labels: '' 6 | assignees: marc-thieme 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe your solution with respect to the output/`pdf`** 14 | - What does your solution change/look like? 15 | - Please focus on the aspects visible in the generated output. 16 | 17 | **Describe how you expect the syntax to look like** 18 | - If you don't know how your feature could work syntactically, you can remove this section 19 | - If you come up with multiple different syntax ideas, please list them all 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | 24 | --- 25 | 26 | - Feel free to delete sections which don't apply 27 | - Feel free to be brief. One sentence might suffice. 28 | -------------------------------------------------------------------------------- /src/utils/encode.typ: -------------------------------------------------------------------------------- 1 | #let _unique-frame-metadata-tag = "_THIS-IS-METADATA-USED-FOR-FRAME-IT-FRAMES" 2 | 3 | // Encode info as invisible metadata so when rendered in outline, only the title is seen 4 | #let encode-title-and-info(kind, title, tags, body, supplement, custom-arg, style) = { 5 | let info = ( 6 | title: title, 7 | tags: tags, 8 | body: body, 9 | supplement: supplement, 10 | custom-arg: custom-arg, 11 | style: style, 12 | kind: kind 13 | ) 14 | // Add "" so when title is `none`, result still has type 'sequence' 15 | metadata((_unique-frame-metadata-tag, info)) + "" + title 16 | } 17 | #let retrieve-info-from-code(code) = code.children.first().value.at(1) 18 | #let code-has-info-attached(code) = ( 19 | code != none 20 | and "children" in code.fields() 21 | and code.children.first() != none 22 | and code 23 | .children 24 | .first() 25 | .fields() 26 | .at("value", default: ()) 27 | .at(0, default: "") 28 | == _unique-frame-metadata-tag 29 | ) 30 | 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Marc Thieme 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib.typ: -------------------------------------------------------------------------------- 1 | #import "styling.typ" as styling: divide 2 | #import "inspect.typ" as inspect 3 | 4 | #let styles = { 5 | import "styles/boxy.typ": boxy 6 | import "styles/hint.typ": hint 7 | import "styles/thmbox.typ": thmbox 8 | (boxy: boxy, hint: hint, thmbox: thmbox) 9 | } 10 | 11 | #let default-kind = "frame" 12 | 13 | #let frames( 14 | kind: default-kind, 15 | base-color: purple.lighten(60%).desaturate(40%), 16 | ..frames, 17 | ) = { 18 | import "parse.typ": fill-missing-colors 19 | import "layout.typ": frame-factory 20 | 21 | for (id, supplement, color) in fill-missing-colors(base-color, frames) { 22 | ((id): frame-factory(kind, supplement, color)) 23 | } 24 | } 25 | 26 | #let frame( 27 | kind: default-kind, 28 | supplement, 29 | arg, 30 | ) = { 31 | import "layout.typ": frame-factory 32 | 33 | frame-factory(kind, supplement, arg) 34 | } 35 | 36 | #let frame-style(kind: default-kind, style) = { 37 | import "layout.typ" as layout 38 | layout.frame-style(kind, style) 39 | } 40 | 41 | /* 42 | Definition of styling: 43 | 44 | let factory(title: content, tags: (content), body: content, supplement: string or content, number, args) 45 | */ 46 | -------------------------------------------------------------------------------- /src/parse.typ: -------------------------------------------------------------------------------- 1 | #let calculate-colors(base-color, count) = ( 2 | range(count) 3 | .map(i => ( 4 | i / count * 360deg 5 | )) 6 | .map(rotation => base-color.rotate(rotation)) 7 | ) 8 | 9 | #let fill-missing-colors( 10 | base-color, 11 | frames, 12 | ) = { 13 | assert( 14 | frames.pos() == (), 15 | message: "Unexpected positional arguments: " + repr(frames.pos()), 16 | ) 17 | 18 | // Canonicalize and validate arguments 19 | let args = for (id, args) in frames.named() { 20 | if type(args) != array { 21 | args = (args,) 22 | } 23 | let (supplement, col, ..) = ( 24 | args 25 | + ( 26 | auto, 27 | ) 28 | ) // Denote color with 'auto' if omitted 29 | assert(type(supplement) in (content, str)) 30 | assert( 31 | type(col) in (color, type(auto)), 32 | message: "Please provide a color as second arguments: " 33 | + supplement 34 | + " (was " 35 | + repr(type(col)) 36 | + ")", 37 | ) 38 | ((id, supplement, col),) 39 | } 40 | 41 | // Count auto in args and generate colors 42 | let auto-count = args.filter(((_, _, col)) => col == auto).len() 43 | let generated-colors = calculate-colors(base-color, auto-count) 44 | let next-color-idx = 0 45 | 46 | // Replace auto with respective colors 47 | for (id, supplement, col) in args { 48 | if col == auto { 49 | col = generated-colors.at(next-color-idx) 50 | next-color-idx += 1 51 | } 52 | ((id, supplement, col),) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.0 2 | ### API 3 | - BREAKING: remove deprecated `make-frames` function 4 | - remove the need for `--input=html-frames` argument for html export 5 | 6 | ### Fixes 7 | - fix(styles): support RTL layout [#14](https://github.com/marc-thieme/frame-it/issues/14) 8 | - fix(api): handle `none` as figure numbering [#15](https://github.com/marc-thieme/frame-it/pull/15) 9 | 10 | ## 1.2.0 11 | ### Features 12 | - feat(layout): add frame function to create a single frame 13 | - docs(readme): showcase syntax for individual frame creation 14 | 15 | ### Fixes 16 | - fix(layout): `frame-style` only applies to specific kind 17 | - fix(styles): accept tags without title in thmbox 18 | - fix: polylux presentation compatibility 19 | - fix(inspection): `is-frame` works for all content 20 | 21 | ### Implementation 22 | - refactor(layout): ad–hoc style by placing it into metadata 23 | 24 | ## 1.1.2 25 | - Add inspection functions `lookup-frame-info` and `is-frame` 26 | - Add suggestions for abbreviations to readme 27 | - Fix compilation without feature flag html 28 | 29 | ## 1.1.1 30 | - Rework README building 31 | - Add abitlity to export to HTML 32 | - Support dark theme for thmbox styling 33 | 34 | ## 1.1.0 35 | - Add new styling 36 | - In the `(make-)frames`–function, allow supplement to be supplied as single value 37 | instead of array 38 | - Pass additional arguments in a frame function onto the figure function 39 | when placing it in the document 40 | - Change API to declare the styling function to use using a show rule 41 | - Refactor layouting system to be simpler and more robust 42 | - Make frames breakable across pages 43 | - Add version of readme for dark mode on GitHub 44 | - Influence the auto–generated colors for the frames using `base-color` parameter 45 | - Improve Readme 46 | 47 | ## 1.0.0 48 | - Design syntax which minimizes redundancy, is flexible and easy to use 49 | - Create default style `boxy` and `hint` 50 | - Separate components and identify easy api for styling functions 51 | - If colors are missing, generate colors spanning the rainbow 52 | - Add a Readme which is compiled from Typst 53 | -------------------------------------------------------------------------------- /src/utils/html.typ: -------------------------------------------------------------------------------- 1 | #let wants-html() = { 2 | "target" in dictionary(std) and target() == "html" 3 | } 4 | 5 | #let target-choose(html: auto, paged: auto) = context { 6 | assert( 7 | html != auto and paged != auto, 8 | message: "Please provide options for both `html` and `paged`.", 9 | ) 10 | if wants-html() { 11 | if type(html) == function { html() } else { html } 12 | } else { 13 | if type(paged) == function { paged() } else { paged } 14 | } 15 | } 16 | 17 | #let elem(tag, body, ..attrs) = { 18 | assert(attrs.pos() == (), message: "You can only provide named arguments.") 19 | assert( 20 | wants-html(), 21 | message: "You can only use the `elem` function in an HTML context.", 22 | ) 23 | let body-arg = if type(body) == function { body() } else { body } 24 | html.elem(tag, attrs: attrs.named(), body-arg) 25 | } 26 | 27 | #let elem-ignore(tag, body, ..attrs) = { 28 | assert(attrs.pos() == (), message: "You can only provide named arguments.") 29 | if wants-html() { 30 | let body-arg = if type(body) == function { body() } else { body } 31 | html.elem(tag, attrs: attrs.named(), body-arg) 32 | } 33 | } 34 | 35 | #let elem-ident(tag, body, ..attrs) = { 36 | assert(attrs.pos() == (), message: "You can only provide named arguments.") 37 | if wants-html() { 38 | let body-arg = if type(body) == function { body() } else { body } 39 | html.elem(tag, attrs: attrs.named(), body-arg) 40 | } else { 41 | body 42 | } 43 | } 44 | 45 | #let span(style, body, ..attrs) = elem( 46 | "span", 47 | body, 48 | style: style, 49 | ..attrs, 50 | ) 51 | #let div(style, body, ..attrs) = elem("div", body, style: style, ..attrs) 52 | #let hr(style, ..attrs) = elem("hr", style: style, ..attrs, none) 53 | 54 | #let css(..args) = { 55 | assert( 56 | args.pos().map(type) in ((), (dictionary,)), 57 | message: "CSS function only accepts named arguments or one dictionary.", 58 | ) 59 | let css-dict = if args.pos().len() == 1 { 60 | assert(args.named() == (:)) 61 | args.pos().first() 62 | } else { 63 | args.named() 64 | } 65 | 66 | let parse(val) = if type(val) == color { 67 | val.to-hex() 68 | } else if type(val) != str { 69 | repr(val) 70 | } else { 71 | val 72 | } 73 | 74 | for (key, value) in css-dict { 75 | value = if type(value) == array { 76 | value.map(parse).join(" ") 77 | } else { 78 | parse(value) 79 | } 80 | key + ": " + value + "; " 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/layout.typ: -------------------------------------------------------------------------------- 1 | #import "styling.typ" 2 | #import "utils/encode.typ": ( 3 | encode-title-and-info, 4 | retrieve-info-from-code, 5 | code-has-info-attached, 6 | ) 7 | 8 | #let spawn-frame( 9 | kind, 10 | title, 11 | tags, 12 | body, 13 | supplement, 14 | custom-arg, 15 | style, 16 | ..figure-params, 17 | ) = { 18 | figure( 19 | caption: encode-title-and-info( 20 | kind, 21 | title, 22 | tags, 23 | body, 24 | supplement, 25 | custom-arg, 26 | style, 27 | ), 28 | supplement: supplement, 29 | kind: kind, 30 | ..figure-params, 31 | none, 32 | ) 33 | } 34 | 35 | #let __frame-id-counter-state = state("__frame-it_frame-id-state", 1) 36 | 37 | #let frame-style( 38 | kind, 39 | style, 40 | ) = document => { 41 | // Don't restrict to correct kind. We already discern between frames and user–figures 42 | // inspecting the metadata. This way, the user can manipulate the kind if desired. 43 | show figure.caption: caption => { 44 | let code = caption.body 45 | if not code-has-info-attached(code) or retrieve-info-from-code(code).kind != kind { 46 | caption 47 | } else { 48 | let number = if caption.numbering != none { 49 | context caption.counter.display(caption.numbering) 50 | } 51 | let ( 52 | title, 53 | tags, 54 | body, 55 | supplement, 56 | custom-arg, 57 | style: bundled-style, 58 | ) = retrieve-info-from-code(code) 59 | let actual-style = if bundled-style != auto { bundled-style } else { 60 | style 61 | } 62 | actual-style(title, tags, body, supplement, number, custom-arg) 63 | } 64 | } 65 | show: styling.dividers-as[ 66 | `Error: Dividers not supported by this styling function.` 67 | ] 68 | 69 | import "utils/html.typ": elem-ident, elem-ignore 70 | show figure 71 | .where(kind: kind) 72 | .or(figure.where(kind: kind + "-wrapper")): it => { 73 | // Necessary because html-figure unfortunately inserts padding we don't want 74 | let id = __frame-id-counter-state.get() 75 | __frame-id-counter-state.update(it => it + 1) 76 | let figure-id = "frame-wrapper-" + str(id) 77 | let style-code = { 78 | "#" + figure-id + " > figure {" 79 | " margin-left: 0px;" 80 | " margin-right: 0px;" 81 | "}" 82 | } 83 | // If not html, this will just equate to { it } 84 | elem-ignore("style", style-code) 85 | elem-ident("div", id: figure-id, it) 86 | } 87 | document 88 | } 89 | 90 | #let frame-factory(kind, supplement, custom-arg) = ( 91 | (..title-and-tags, body, style: auto, arg: custom-arg) => { 92 | let title = none 93 | let tags = () 94 | if title-and-tags.pos() != () { 95 | (title, ..tags) = title-and-tags.pos() 96 | } 97 | spawn-frame( 98 | kind, 99 | title, 100 | tags, 101 | body, 102 | supplement, 103 | arg, 104 | style, 105 | ..title-and-tags.named(), 106 | ) 107 | } 108 | ) 109 | -------------------------------------------------------------------------------- /src/styles/hint.typ: -------------------------------------------------------------------------------- 1 | #import "../styling.typ" as styling 2 | #import "../utils/html.typ": css, span, div, hr, target-choose 3 | 4 | #let body-inset = 0.8em 5 | #let stroke-width = 0.13em 6 | #let line-width = 3.5pt 7 | 8 | #let header-suppl(title, tags, body, supplement, number) = { 9 | if title == none { 10 | none 11 | } else { 12 | if title != [] { 13 | title = [~~#title~] 14 | } 15 | 16 | let tag-str = if tags != () { 17 | [~(#tags.join(", "))~] 18 | } else { 19 | [] 20 | } 21 | let supplement-str = context { 22 | let header-color = text.fill.mix((text.fill.negate(), 20%)) 23 | text(header-color)[#supplement #number] 24 | } 25 | let head-body-separator = if body == [] [] else [:] 26 | [~#supplement-str~*#(title)*_#(tag-str)_#head-body-separator~] 27 | } 28 | } 29 | 30 | #let hint-html(title, tags, body, supplement, number, accent-color) = { 31 | let has-body = body != [] 32 | let has-title = title not in ([], "", none) 33 | let has-headers = int(has-title) + tags.len() > 0 34 | let body-only = title == none 35 | show: styling.dividers-as( 36 | hr( 37 | css( 38 | background: accent-color, 39 | height: stroke-width, 40 | border: 0, 41 | margin: (body-inset, -body-inset), 42 | ), 43 | ), 44 | ) 45 | div( 46 | css( 47 | border-left: (line-width, "solid", accent-color), 48 | padding: body-inset, 49 | ), 50 | { 51 | header-suppl(title, tags, body, supplement, number) 52 | body 53 | }, 54 | ) 55 | } 56 | 57 | #let hint-paged(title, tags, body, supplement, number, accent-color) = { 58 | let stroke = stroke( 59 | thickness: line-width, 60 | paint: accent-color, 61 | cap: "round", 62 | ) 63 | 64 | let header = header-suppl(title, tags, body, supplement, number) 65 | layout(((width,)) => { 66 | let text = { 67 | show: styling.dividers-as({ 68 | v(body-inset - 1em) 69 | line( 70 | length: 100% + body-inset, 71 | start: (-body-inset, 0pt), 72 | stroke: accent-color + stroke-width, 73 | ) 74 | v(body-inset - 1em) 75 | }) 76 | 77 | block( 78 | width: width, 79 | stroke: (left: stroke), 80 | inset: (left: 0.7em, y: 0.7em), 81 | align(left, header + body), 82 | ) 83 | } 84 | 85 | // At both ends of the line drawn by the border, we overlay lines which 86 | // extend them by rounded ends. This looks better. 87 | let length = 0.2em // Arbitrary; if too long, could extend into page margins 88 | place(line(stroke: stroke, angle: 90deg, length: length)) 89 | text 90 | place(line(stroke: stroke, angle: 90deg, length: -length)) 91 | // We prefer this setup to only using the block border because we want the 92 | // rounded edges. We prefer it to one contiguous line because then the 93 | // line would be missing if the hint breaks across two or more pages. 94 | // See: https://github.com/marc-thieme/frame-it/issues/1 95 | }) 96 | } 97 | 98 | #let hint(title, tags, body, supplement, number, accent-color) = target-choose( 99 | html: () => hint-html(title, tags, body, supplement, number, accent-color), 100 | paged: hint-paged(title, tags, body, supplement, number, accent-color), 101 | ) 102 | -------------------------------------------------------------------------------- /src/styles/thmbox.typ: -------------------------------------------------------------------------------- 1 | #import "../styling.typ" as styling 2 | #import "../utils/html.typ": css, span, div, hr, target-choose 3 | 4 | #let line-width = 3pt 5 | #let body-inset = 1em 6 | #let text-color(accent-color) = ( 7 | accent-color.mix((text.fill.lighten(80%), 100%)).saturate(60%) 8 | ) 9 | 10 | 11 | #let thmbox-html(title, tags, body, supplement, number, accent-color) = { 12 | let has-body = body != [] 13 | let has-title = title not in ([], "", none) 14 | let has-headers = int(has-title) + tags.len() > 0 15 | let body-only = title == none 16 | div( 17 | css( 18 | border-left: (line-width, "solid", accent-color), 19 | padding: body-inset, 20 | ), 21 | { 22 | if not body-only { 23 | context div( 24 | css( 25 | ..( 26 | if has-headers { 27 | ( 28 | display: "flex", 29 | justify-content: "space-between", 30 | align-items: center, 31 | ) 32 | } else { (:) } 33 | ), 34 | color: text-color(accent-color), 35 | ), 36 | { 37 | if has-title { 38 | div(css(flex: "1.7 1 1", text-align: left), strong(title)) 39 | } 40 | let is-first = true 41 | for tag in tags { 42 | div(css(flex: "1 1 1", text-align: center), tag) 43 | is-first = false 44 | } 45 | div( 46 | css( 47 | flex: "1.7 1 1", 48 | text-align: if has-headers { right } else { left }, 49 | ), 50 | strong(supplement + " " + number), 51 | ) 52 | }, 53 | ) 54 | } 55 | show: styling.dividers-as( 56 | hr( 57 | css( 58 | background: accent-color, 59 | height: 0.18em, 60 | border: 0, 61 | margin: (0, 0, 0, -body-inset), 62 | ), 63 | ), 64 | ) 65 | body 66 | }, 67 | ) 68 | } 69 | 70 | // Credits to https://github.com/s15n/typst-thmbox 71 | #let thmbox-paged(title, tags, body, supplement, number, accent-color) = { 72 | let has-body = body != [] 73 | let has-title = title not in ([], "", none) 74 | let has-tags = tags.len() > 0 75 | let has-headers = has-title or has-tags 76 | let body-only = title == none 77 | 78 | let bar = stroke(paint: accent-color, thickness: line-width) 79 | 80 | show: styling.dividers-as({ 81 | line( 82 | length: 100% + 1em, 83 | start: (-1em, 0pt), 84 | stroke: accent-color + line-width * 0.8, 85 | ) 86 | v(-0.2em) 87 | }) 88 | 89 | block( 90 | stroke: ( 91 | left: bar, 92 | ), 93 | inset: ( 94 | left: 1em, 95 | top: 0.6em, 96 | bottom: 0.6em, 97 | ), 98 | spacing: 1.2em, 99 | )[ 100 | #set align(left) 101 | // Title bar 102 | #if not body-only { 103 | block( 104 | above: 0em, 105 | below: 1.2em, 106 | context { 107 | set text(text-color(accent-color), weight: "bold") 108 | if has-title { 109 | title 110 | } 111 | if has-headers { 112 | h(3fr) 113 | } 114 | if has-tags { 115 | for tag in tags { 116 | text(tag, weight: "regular") 117 | h(1fr) 118 | } 119 | h(2fr) 120 | } 121 | supplement 122 | " " 123 | number 124 | }, 125 | ) 126 | } 127 | // Body 128 | #if has-body { 129 | block( 130 | inset: ( 131 | right: 1em, 132 | ), 133 | spacing: 0em, 134 | width: 100%, 135 | body, 136 | ) 137 | } 138 | ] 139 | } 140 | 141 | #let thmbox( 142 | title, 143 | tags, 144 | body, 145 | supplement, 146 | number, 147 | accent-color, 148 | ) = target-choose( 149 | html: () => thmbox-html(title, tags, body, supplement, number, accent-color), 150 | paged: thmbox-paged(title, tags, body, supplement, number, accent-color), 151 | ) 152 | 153 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set unstable 2 | 3 | readme-typ-file := 'README.typ' 4 | tmpdir := env('XDG_RUNTIME_DIR', '/tmp') / 'frame-it' 5 | _dummy := shell('mkdir -p ' + tmpdir) 6 | compile-html := 'typst compile --features html -f "html"' 7 | 8 | unexport TYPST_FEATURES 9 | 10 | default: 11 | just --list 12 | 13 | # –––––– [ Release ] –––––– 14 | _version-regex := '[0-9]+\.[0-9]+\.[0-9]+' 15 | release new-version packages-repo-root: && (update-and-push-assets "Release version {{new-version}}") (cp-to-packages new-version packages-repo-root) 16 | @echo Testing if index and staging area are empty 17 | test -z "$(git status --porcelain)" 18 | sed -Ei 's|#import "@preview/frame-it:{{_version-regex}}"|#import "@preview/frame-it:{{new-version}}"|g' {{readme-typ-file}} 19 | sed -Ei 's|version = "{{_version-regex}}"|version = "{{new-version}}"|g' typst.toml 20 | sed -i "s/CURRENT/{{new-version}}/" CHANGELOG.md 21 | git add {{readme-typ-file}} typst.toml CHANGELOG.md 22 | git commit -m "Bump version to {{new-version}}." 23 | test -z "$(git status --porcelain)" # Just to make sure we didn't screw up 24 | git tag -a {{new-version}} 25 | @echo Don\'t forget to open a pull request for the new version! 26 | 27 | _packages-suffix := "packages/preview/frame-it/" 28 | [script] 29 | cp-to-packages new-version packages-repo-root: 30 | folder={{packages-repo-root / _packages-suffix / new-version}} 31 | echo $folder 32 | rm $folder -rf 33 | cp . $folder -r 34 | cd $folder 35 | rm .github/ assets/ .git/ -rf 36 | rm CHANGELOG.md justfile .gitignore .typos.toml .envrc -f 37 | sed -i 's|^#import "src/lib.typ"|#import "@preview/frame-it:{{new-version}}"|g' README.typ 38 | find . -type f -name "*.pdf" | xargs rm 39 | 40 | 41 | [script('nu')] 42 | update-html dir: 43 | ^mkdir -p {{dir}}/assets 44 | let light = {{compile-html}} {{readme-typ-file}} - | htmlq "body > *" 45 | let dark = {{compile-html}} --input theme=dark {{readme-typ-file}} - | htmlq "body > *" 46 | let light_split = ^cat assets/README-stub.html | split row LIGHT 47 | let full_split = [$light_split.0, ...($light_split.1 | split row DARK)] 48 | echo $full_split.0 $light $full_split.1 $dark $full_split.2 | str join 49 | | htmlq -r 'body > div > style:first-child' | save -f {{dir}}/assets/README.html 50 | 51 | [script('nu')] 52 | update-readme dir: 53 | {{compile-html}} --input svg-frames=true {{readme-typ-file}} {{tmpdir / "light.html"}} 54 | {{compile-html}} --input svg-frames=true --input theme=dark {{readme-typ-file}} {{tmpdir / "dark.html"}} 55 | ^cat {{tmpdir / "light.html"}} | pandoc -f html -t gfm -o {{tmpdir / "README-v1.md"}} 56 | 57 | let svgs_light = htmlq -f {{tmpdir / "light.html"}} "svg" | split row " 0} | each {" 0} | each {" [!NOTE] 70 | > This is the version of the readme adapted for the Github Readme. 71 | This adaption is less than ideal. 72 | If you want to copy text and have a faithful render, go to [this link](https://html-preview.github.io/?url=https://github.com/marc-thieme/frame-it/blob/assets/README.html). 73 | ' 74 | | cat - {{tmpdir / "README-v1.md"}} | save -f {{tmpdir / "README-v2.md"}} 75 | 76 | mut readme = open {{tmpdir / "README-v2.md"}} 77 | 78 | def "str erase" [...patterns: string] { 79 | let text = $in 80 | $patterns | reduce --fold $text { |pattern, acc| $acc | str replace -rma $pattern '' } 81 | } 82 | 83 | for i in 0..($svgs_light | length) { 84 | let idx = $i | into string 85 | $readme = $readme | str replace -rm '' (' 86 | 87 | 88 | 89 | 90 | ' | str replace -ra '\s+' ' ') 91 | } 92 | 93 | $readme | str erase '\n*^$' '^\s*\s*$' | save -f {{dir / "README.md"}} 94 | 95 | check-style staging-only="false": 96 | typos --exclude '*.html' 97 | typstyle --check \ 98 | $({{if staging-only == "false" {"find"} else {"git diff-index --cached --name-only HEAD"} }}\ 99 | | grep '\.typ') README.typ \ 100 | > /dev/null 101 | 102 | update-assets: (update-html ".") && (update-readme ".") 103 | git add README.md # Make sure not to override changes in README 104 | 105 | test-compile: (update-html tmpdir) (update-readme tmpdir) 106 | typst compile README.typ {{tmpdir / "README.pdf"}} 107 | 108 | [confirm("Do you want to commit and push all changes on the assets branch?")] 109 | [working-directory("assets")] 110 | update-and-push-assets commit-msg="chore: update": update-assets 111 | git add . 112 | git commit -m "{{commit-msg}}" --no-verify 113 | git push 114 | 115 | # –––––– [ Setup ] –––––– 116 | setup: setup-pre-commit-hooks && _add-assets-to-git-exclude 117 | git worktree add assets assets 118 | 119 | [confirm("Add pre-commit hook to .git/hooks/pre-commit?")] 120 | setup-pre-commit-hooks: 121 | touch .git/hooks/pre-commit 122 | chmod +x .git/hooks/pre-commit 123 | echo "just pre-commit" >> .git/hooks/pre-commit 124 | 125 | [confirm("Add new worktree 'assets' to '.git/info/exclude'?")] 126 | _add-assets-to-git-exclude: 127 | echo assets >> .git/info/exclude 128 | 129 | pre-commit: (check-style "true") test-compile 130 | 131 | -------------------------------------------------------------------------------- /src/styles/boxy.typ: -------------------------------------------------------------------------------- 1 | #import "../styling.typ" as styling 2 | #import "../utils/html.typ": css, div, hr, span, target-choose 3 | #import "../utils/text-queries.typ": real-text-direction, text-is-left-to-right 4 | 5 | #let body-inset = 0.8em 6 | #let stroke-width = 0.13em 7 | #let corner-radius = 5pt 8 | 9 | #let boxy-html(title, tags, body, supplement, number, accent-color) = { 10 | let has-body = body != [] 11 | let has-title = title not in ([], "", none) 12 | let has-headers = int(has-title) + tags.len() > 0 13 | let body-only = title == none 14 | let header-contents = tags 15 | if has-title { 16 | header-contents.insert(0, title) 17 | } 18 | 19 | let header-styles = ( 20 | padding: "5px 10px", 21 | border: (stroke-width, "solid", accent-color), 22 | margin-left: "-2px", 23 | ) 24 | if has-body { 25 | header-styles.border-bottom = none 26 | } 27 | let first-header-elem-css-overlay = { 28 | ( 29 | margin-left: 0, 30 | border-top-left-radius: corner-radius, 31 | ) 32 | if not has-body { 33 | (border-bottom-left-radius: corner-radius) 34 | } 35 | } 36 | let last-header-elem-css-overlay = { 37 | ( 38 | border-top-right-radius: corner-radius, 39 | ) 40 | if not has-body { 41 | (border-bottom-right-radius: corner-radius) 42 | } 43 | } 44 | let html-suppl = span( 45 | css( 46 | ..header-styles, 47 | border-color: "transparent", 48 | margin-left: if has-headers { auto } else { 0 }, 49 | mragin-right: body-inset, 50 | ), 51 | supplement + " " + number, 52 | ) 53 | let headers-html() = { 54 | let header-contents = if has-title { 55 | (title, ..tags) 56 | } else { 57 | tags 58 | } 59 | let header-css = (header-styles,) * header-contents.len() 60 | header-css.first() += first-header-elem-css-overlay 61 | header-css.last() += last-header-elem-css-overlay 62 | 63 | if has-title { 64 | header-css.first() += (background-color: accent-color) 65 | } 66 | for (content, css-dict) in header-contents.zip(header-css) { 67 | span(css(..css-dict), content) 68 | } 69 | } 70 | if not body-only { 71 | div( 72 | css(display: "flex", align-items: center), 73 | { 74 | if has-headers { 75 | headers-html() 76 | } 77 | html-suppl 78 | }, 79 | ) 80 | } 81 | if has-body { 82 | show: styling.dividers-as({ 83 | let css-dict = ( 84 | border: 0, 85 | height: stroke-width, 86 | background: accent-color, 87 | margin: (0, -body-inset), 88 | ) 89 | hr(css(css-dict)) 90 | }) 91 | let css-dict = ( 92 | border: (stroke-width, "solid", accent-color), 93 | border-radius: (0, corner-radius, corner-radius, corner-radius), 94 | padding: body-inset, 95 | ) 96 | if not has-headers { 97 | css-dict.border-top-left-radius = corner-radius 98 | } 99 | div( 100 | css(css-dict), 101 | body, 102 | ) 103 | } 104 | } 105 | 106 | #let boxy-paged(title, tags, body, supplement, number, accent-color) = { 107 | assert( 108 | type(accent-color) == color, 109 | message: "Please provide a color as argument for the frame instance" 110 | + supplement, 111 | ) 112 | 113 | let stroke = accent-color + stroke-width 114 | 115 | let round-bottom-corners-of-tags = body == [] 116 | let display-title = title not in ([], "") 117 | 118 | let header() = align( 119 | start, 120 | { 121 | let inset = 0.5em 122 | 123 | let tag-elements = tags 124 | if display-title { 125 | let title-cell = grid.cell(fill: accent-color, title) 126 | tag-elements.insert(0, title-cell) 127 | } 128 | 129 | let rounded-corners = (top: corner-radius) 130 | if round-bottom-corners-of-tags { 131 | rounded-corners.bottom = corner-radius 132 | } 133 | 134 | let rendered-tags = if tag-elements == () [] else { 135 | let grid-cells = tag-elements.intersperse(grid.vline(stroke: stroke)) 136 | let tag-grid = grid(columns: tag-elements.len(), align: horizon, inset: inset, ..grid-cells) 137 | box(clip: true, stroke: stroke, radius: rounded-corners, tag-grid) 138 | h(1fr) 139 | } 140 | 141 | let supplement-str = box(inset: inset, context stack( 142 | dir: real-text-direction(), 143 | supplement, 144 | [~], 145 | number, 146 | )) 147 | 148 | layout(((width: available-width)) => { 149 | if measure(rendered-tags + supplement-str).width < available-width { 150 | rendered-tags 151 | supplement-str 152 | } else [ 153 | #supplement #number \ 154 | #rendered-tags 155 | ] 156 | }) 157 | }, 158 | ) 159 | 160 | let board() = context { 161 | let sharp-top-left-body-corner = title not in ([], none) or tags != () 162 | let round-corners = (rest: corner-radius) 163 | if sharp-top-left-body-corner { 164 | if text-is-left-to-right() { 165 | round-corners.top-left = 0% 166 | } else { 167 | round-corners.top-right = 0% 168 | } 169 | } 170 | align( 171 | start, 172 | block( 173 | width: 100%, 174 | inset: body-inset, 175 | radius: round-corners, 176 | stroke: stroke, 177 | spacing: 0em, 178 | outset: (y: 0em), 179 | { 180 | // Divide constructs a line where we need to inject the stroke style because we only have access to the color here 181 | show: styling.dividers-as({ 182 | v(-0.5em + stroke-width) 183 | line( 184 | start: (-body-inset, 0em), 185 | length: 100% + 2 * body-inset, 186 | stroke: stroke, 187 | ) 188 | v(-0.5em + stroke-width) 189 | }) 190 | body 191 | }, 192 | ), 193 | ) 194 | } 195 | 196 | let parts = () 197 | 198 | let rounded-corners = (bottom: corner-radius) 199 | 200 | if title != none { 201 | parts.push(header()) 202 | } 203 | 204 | if body != [] { 205 | parts.push(board()) 206 | } 207 | 208 | stack(..parts) 209 | } 210 | 211 | #let boxy(title, tags, body, supplement, number, accent-color) = target-choose( 212 | html: () => boxy-html(title, tags, body, supplement, number, accent-color), 213 | paged: boxy-paged(title, tags, body, supplement, number, accent-color), 214 | ) 215 | -------------------------------------------------------------------------------- /README.typ: -------------------------------------------------------------------------------- 1 | #import "src/lib.typ": * 2 | #import "src/utils/html.typ": * 3 | 4 | #let base-color-arg = (:) 5 | #let text-color = black 6 | #let background-color = white 7 | #if sys.inputs.at("theme", default: "light") == "dark" { 8 | text-color = rgb(240, 246, 252) 9 | background-color = rgb("#0d1117") 10 | base-color-arg.base-color = blue.darken(40%).desaturate(25%) 11 | } 12 | #let example-color = text-color.mix((text-color.negate(), 590%)).mix(gray) 13 | 14 | #let (example, feature, variant, syntax) = frames( 15 | ..base-color-arg, 16 | feature: "Feature", 17 | variant: ("Feature Variant",), 18 | example: ("Example", example-color), 19 | syntax: ("Syntax",), 20 | ) 21 | 22 | #set text(text-color) 23 | #set text(15pt) 24 | 25 | #let wants-svg-frames = sys.inputs.at("svg-frames", default: "false") != "false" 26 | #show figure.where(kind: "frame"): it => context if ( 27 | wants-html() and wants-svg-frames 28 | ) { 29 | html.frame({ 30 | v(2mm) 31 | block(width: 24cm, it) 32 | v(2mm) 33 | }) 34 | } else { it } 35 | 36 | #show raw.where(lang: "typst"): code => context if wants-html() { 37 | html.elem( 38 | "pre", 39 | html.elem( 40 | "code", 41 | attrs: (class: "typst"), 42 | code, 43 | ), 44 | ) 45 | } else { code } 46 | 47 | #show: it => context if not wants-html() { 48 | set page(fill: background-color) 49 | set page(height: auto, margin: 4mm) 50 | it 51 | } else { it } 52 | 53 | #show: frame-style(styles.boxy) 54 | 55 | = Introduction 56 | #link("https://github.com/marc-thieme/frame-it", text(blue)[Frame-It]) offers a straightforward way to define and use custom environments in your documents. Its syntax is designed to integrate seamlessly with your source code. 57 | 58 | Two predefined styles are included by default. You can also create custom styling functions that use the same user-facing API while giving you complete control over the Typst elements in your document. 59 | 60 | #feature[Distinct Highlight][Best for occasional use][More noticeable][ 61 | The default style, `styles.boxy`, is eye-catching and intended to stand out from the surrounding text. 62 | ] 63 | 64 | In contrast: 65 | 66 | #feature( 67 | style: styles.hint, 68 | )[Unobtrusive Style][Ideal for frequent use][Blends into text flow][ 69 | The alternative style `styles.hint` highlights text with a subtle colored line along the side, preserving the document's flow. 70 | ] 71 | 72 | #feature( 73 | style: styles.thmbox, 74 | )[Elegant][Separates header and content][not too obtrusive][ 75 | The third default style `styles.hint` clearly separates frame header and frame body. 76 | This style was taken from [typst-thmbox](https://github.com/s15n/typst-thmbox). 77 | ] 78 | 79 | The default styles are merely functions with the correct signature. 80 | If they don't appeal to you, you have complete freedom to define custom styling functions yourself. 81 | For reference, this is that signature: 82 | ```typst 83 | let your-custom-styling-function(title, tags, body, supplement, number, accent-color) = […] 84 | ``` 85 | 86 | #example[A different frame kind][ 87 | You can define different classes or types of frames, which alter the substitute and the frame's color. As shown here, this is an example frame. 88 | You can create as many different kinds as you want. 89 | 90 | As long as all kinds use the same kind, they share a common counter. 91 | ] 92 | 93 | = Quick Start 94 | Import and define your desired frames: 95 | 96 | ```typst 97 | #import "@preview/frame-it:1.2.0": * 98 | 99 | #let (example, feature, variant, syntax) = frames( 100 | feature: ("Feature",), 101 | // For each frame kind, you have to provide its supplement title to be displayed 102 | variant: ("Variant",), 103 | // You can provide a color or leave it out and it will be generated 104 | example: ("Example", gray), 105 | ) 106 | // This syntax works as well, but colors are not generated automatically 107 | #let syntax = frame("Syntax", green) 108 | // This is necessary. Don't forget this! 109 | #show: frame-style(styles.boxy) 110 | ``` 111 | 112 | How to use it is explained below. Here is a quick example: 113 | ```typst 114 | #example[Title][Optional Tag][ 115 | Body, i.e. large content block for the frame. 116 | ] 117 | ``` 118 | which yields 119 | #example[Title][Optional Tag][ 120 | Body, i.e. large content block for the frame. 121 | ] 122 | 123 | = Feature List 124 | 125 | #let layout-features() = [ 126 | #feature[Element with Title and Content][ 127 | The simplest way to create an element is by providing a title as the first argument and content as the second. 128 | ] 129 | 130 | #variant[Element with Tags][Customizable Tags][Multiple][ 131 | Elements can include multiple tags placed between the title and the content. 132 | ] 133 | 134 | #feature[][ 135 | If you don’t require a custom title but still want to display the element type, use `[]` as the title placeholder. 136 | ] 137 | 138 | #variant[][Single Tag][Next tag][ 139 | You can include tags even when no title is provided. 140 | ] 141 | 142 | #variant[ 143 | To omit the header entirely, leave the title parameter empty. 144 | ] 145 | 146 | #feature[Element without Content][Optional Tags Only][] 147 | For brief elements, use [] as the body to omit the content. 148 | 149 | #feature[Element with Divider][ 150 | Insert ```typst #divide()``` to add a divider within your content for a visual break: 151 | #divide() 152 | And then continue with your text below the divider. 153 | ] 154 | ] 155 | 156 | The following features are demonstrated in all predefined styles. 157 | 158 | == Seamlessly hightight parts of your document 159 | #[ 160 | #show: frame-style(styles.hint) 161 | #layout-features() 162 | ] 163 | == Highlight parts distinctively 164 | #[ 165 | #show: frame-style(styles.boxy) 166 | #layout-features() 167 | ] 168 | == A third Alternative 169 | #[ 170 | #show: frame-style(styles.thmbox) 171 | We recently added third style, namely `styles.thmbox`: 172 | #layout-features() 173 | ] 174 | == Miscellaneous 175 | === Syntax to create single frames 176 | #syntax[Single–Frame–Creation][ 177 | When the above method for creating frames is not flexible enough, you can use this alternative method 178 | for creating frames one–by–one: 179 | ```typ 180 | #let theorem = frame("Theorem", red) 181 | #let definition = frame(kind: "not-the-default", "Definition", blue) 182 | ``` 183 | Contrary to the first method, you have to specify a color. 184 | ] 185 | 186 | === HTML 187 | We were one of the first packages to add support for html! \ 188 | This means you can use our frames if you're writing an html wiki or blog in typst. 189 | #feature[HTML export][ 190 | Due to the currently experimental nature of the feature, you need to pass two CLI–flags when 191 | compiling your document with the frames to html: 192 | ``` 193 | typst compile --features html --format html FILE.typ 194 | ``` 195 | ] 196 | 197 | === General 198 | Internally, every frame is just a `figure` where the `kind` is set to `"frame"` (or a different custom value). 199 | As such, most things that can be done to a figure can be done with a frame as well. 200 | Whenever you would like to do something custom but don't know if it is supported, 201 | try achieving it with a normal figure first and then apply the same show rule to your frames. 202 | Here is a list of examples: 203 | 204 | #variant[Labels and References][ 205 | Elements can be referenced as expected by appending `