├── .envrc ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── example-1.png ├── example-2.png ├── example-3.png ├── example-4.png ├── example-5.png ├── example-6.png ├── example-7.png ├── example-8.png └── example-9.png ├── docs ├── manual.pdf ├── manual.typ ├── overview │ ├── main.pdf │ └── main.typ └── utils.typ ├── drafting.typ ├── flake.lock ├── flake.nix ├── package.py ├── release-instructions.md ├── tests ├── columns.typ ├── link-type.pdf ├── link-type.typ └── misc.typ ├── typst.toml └── update_readme.sh /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .direnv 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Margin Notes 2 | 3 | ## Setup 4 | 5 | `drafting` exists in the official typst package repository, so the 6 | recommended approach is to import it from the `preview` namespace: 7 | 8 | ``` typst 9 | #import "@preview/drafting:0.2.2" 10 | ``` 11 | 12 | Margin notes cannot lay themselves out correctly until they know your 13 | page size and margins. By default, they occupy nearly the entirety of 14 | the left or right margin, but you can provide explicit left/right bounds 15 | if desired: 16 | 17 | ``` typ 18 | // Example: 19 | // Default margin in typst is 2.5cm, but we want to use 2cm 20 | // On the left 21 | #set-page-properties(margin-left: 2cm) 22 | ``` 23 | 24 | ## The basics 25 | 26 | ``` typst 27 | #lorem(20) 28 | #margin-note(side: left)[Hello, world!] 29 | #lorem(10) 30 | #margin-note[Hello from the other side] 31 | #margin-note[When notes are about to overlap, they're automatically shifted] 32 | #margin-note(stroke: aqua + 3pt)[To avoid collision] 33 | #lorem(25) 34 | #margin-note(stroke: green, side: left)[You can provide two positional arguments if you want to highlight a phrase associated with your note.][The first is text which should be inline-noted, and the second is the standard margin note.] 35 | 36 | #let caution-rect = rect.with(inset: 1em, radius: 0.5em) 37 | #inline-note(rect: caution-rect, fill: orange.lighten(80%))[ 38 | Be aware that `typst` will complain when 4 notes overlap, and stop automatically avoiding collisions when 5 or more notes 39 | overlap. This is because the compiler stops attempting to reposition notes after a few attempts 40 | (initial layout + adjustment for each note). 41 | 42 | You can manually adjust the position of notes with `dy` to silence the warning. 43 | ] 44 | ``` 45 | ![Example 1](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-1.png) 46 | 47 | ## Adjusting the default style 48 | 49 | All function defaults are customizable through updating the module 50 | state: 51 | 52 | ``` typst 53 | #lorem(14) #margin-note[Default style] 54 | #lorem(10) 55 | #set-margin-note-defaults(stroke: orange, side: left) 56 | #margin-note[Updated style] 57 | #lorem(10) 58 | ``` 59 | ![Example 2](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-2.png) 60 | 61 | Even deeper customization is possible by overriding the default `rect`: 62 | 63 | ``` typst 64 | #import "@preview/colorful-boxes:1.1.0": stickybox 65 | 66 | #let default-rect(stroke: none, fill: none, width: 0pt, content) = { 67 | stickybox(rotation: 30deg, width: width/1.5)[ 68 | #set text(0.9em) 69 | #content 70 | ] 71 | } 72 | #set-margin-note-defaults(rect: default-rect, stroke: none, side: right) 73 | 74 | #lorem(20) 75 | #margin-note(dy: -5em)[Why not use sticky notes in the margin?] 76 | 77 | // Undo changes from this example 78 | #set-margin-note-defaults(rect: rect, stroke: red) 79 | ``` 80 | ![Example 3](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-3.png) 81 | 82 | ## Multiple document reviewers 83 | 84 | ``` typst 85 | #let reviewer-a = margin-note.with(stroke: blue) 86 | #let reviewer-b = margin-note.with(stroke: purple) 87 | #lorem(10) 88 | #reviewer-a[Comment from reviewer A] 89 | #lorem(5) 90 | #reviewer-b(side: left)[Reviewer B comment] 91 | #lorem(10) 92 | ``` 93 | ![Example 4](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-4.png) 94 | 95 | ## Inline Notes 96 | 97 | ``` typst 98 | #lorem(10) 99 | #inline-note[The default inline note will split the paragraph at its location] 100 | #lorem(10) 101 | #inline-note(par-break: false, stroke: (paint: orange, dash: "dashed"))[ 102 | But you can specify `par-break: false` to prevent this 103 | ] 104 | #lorem(10) 105 | ``` 106 | ![Example 5](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-5.png) 107 | 108 | ## Hiding notes for print preview 109 | 110 | ``` typst 111 | #set-margin-note-defaults(hidden: true) 112 | 113 | #lorem(20) 114 | #margin-note[This will respect the global "hidden" state] 115 | #margin-note(hidden: false, dy: -2.5em)[This note will never be hidden] 116 | // Undo these changes 117 | #set-margin-note-defaults(hidden: false) 118 | ``` 119 | ![Example 6](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-6.png) 120 | 121 | # Outline of all notes 122 | 123 | ``` typst 124 | #note-outline() 125 | ``` 126 | ![Example 7](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-7.png) 127 | 128 | # Positioning 129 | 130 | ## Precise placement: rule grid 131 | 132 | Need to measure space for fine-tuned positioning? You can use 133 | `rule-grid` to cross-hatch the page with rule lines: 134 | 135 | ``` typst 136 | #rule-grid(width: 10cm, height: 3cm, spacing: 20pt) 137 | #place( 138 | dx: 180pt, 139 | dy: 40pt, 140 | rect(fill: white, stroke: red, width: 1in, "This will originate at (180pt, 40pt)") 141 | ) 142 | 143 | // Optionally specify divisions of the smallest dimension to automatically calculate 144 | // spacing 145 | #rule-grid(dx: 10cm + 3em, width: 3cm, height: 1.2cm, divisions: 5, square: true, stroke: green) 146 | 147 | // The rule grid doesn't take up space, so add it explicitly 148 | #v(3cm + 1em) 149 | ``` 150 | ![Example 8](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-8.png) 151 | 152 | ## Absolute positioning 153 | 154 | What about absolutely positioning something regardless of margin and 155 | relative location? `absolute-place` is your friend. You can put content 156 | anywhere: 157 | 158 | ``` typst 159 | #context { 160 | let (dx, dy) = (here().position().x, here().position().y) 161 | let content-str = ( 162 | "This absolutely-placed box will originate at (" + repr(dx) + ", " + repr(dy) + ")" 163 | + " in page coordinates" 164 | ) 165 | absolute-place( 166 | dx: dx, dy: dy, 167 | rect( 168 | fill: green.lighten(60%), 169 | radius: 0.5em, 170 | width: 2.5in, 171 | height: 0.5in, 172 | [#align(center + horizon, content-str)] 173 | ) 174 | ) 175 | } 176 | #v(0.5in) 177 | ``` 178 | ![Example 9](https://www.github.com/ntjess/typst-drafting/raw/v0.2.2/assets/example-9.png) 179 | 180 | The “rule-grid” also supports absolute placement at the top-left of the 181 | page by passing `relative: false`. This is helpful for “rule“-ing the 182 | whole page. 183 | -------------------------------------------------------------------------------- /assets/example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-1.png -------------------------------------------------------------------------------- /assets/example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-2.png -------------------------------------------------------------------------------- /assets/example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-3.png -------------------------------------------------------------------------------- /assets/example-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-4.png -------------------------------------------------------------------------------- /assets/example-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-5.png -------------------------------------------------------------------------------- /assets/example-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-6.png -------------------------------------------------------------------------------- /assets/example-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-7.png -------------------------------------------------------------------------------- /assets/example-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-8.png -------------------------------------------------------------------------------- /assets/example-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/assets/example-9.png -------------------------------------------------------------------------------- /docs/manual.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/docs/manual.pdf -------------------------------------------------------------------------------- /docs/manual.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/tidy:0.4.1" 2 | #import "utils.typ": * 3 | #import "../drafting.typ" 4 | #let module = tidy.parse-module(read("../drafting.typ"), scope: (drafting: drafting, ..dictionary(drafting))) 5 | 6 | // Inspiration: https://github.com/typst/packages/blob/main/packages/preview/cetz/0.1.0/manual.typ 7 | // This is a wrapper around typst-doc show-module that 8 | // strips all but one function from the module first. 9 | // As soon as typst-doc supports examples, this is no longer 10 | // needed. 11 | #let show-module-fn(module, fn, ..args) = { 12 | module.functions = module.functions.filter(f => f.name == fn) 13 | module.variables = module.variables.filter(v => v.name == fn) 14 | tidy.show-module( 15 | module, 16 | ..args.pos(), 17 | ..args.named(), 18 | show-module-name: false, 19 | show-outline: false, 20 | enable-cross-references: false, 21 | ) 22 | } 23 | 24 | #show raw.where(lang: "standalone"): text => { 25 | standalone-margin-note-example(raw(text.text, lang: "typ")) 26 | } 27 | 28 | #show raw.where(lang: "standalone-ttb"): text => { 29 | standalone-margin-note-example(raw(text.text, lang: "typ"), direction: ttb) 30 | } 31 | 32 | #show raw.where(lang: "example"): content => { 33 | set text(font: "Libertinus Serif") 34 | example-with-source(content.text, drafting: drafting, direction: ltr) 35 | } 36 | 37 | #show raw.where(lang: "example-ttb"): content => { 38 | set text(font: "Libertinus Serif") 39 | example-with-source(content.text, drafting: drafting) 40 | } 41 | 42 | #show-module-fn(module, "margin-note-defaults") 43 | 44 | #show-module-fn(module, "margin-note") 45 | ```standalone 46 | = Document Title 47 | #lorem(3) 48 | #margin-note(side: left)[Left note] 49 | #margin-note[right note] 50 | #margin-note(stroke: green)[Green stroke, auto-offset] 51 | #lorem(10) 52 | #margin-note(side: left, dy: 10pt)[Manual offset] 53 | #lorem(10) 54 | ``` 55 | 56 | #show-module-fn(module, "inline-note") 57 | ```example 58 | = Document Title 59 | #lorem(7) 60 | #inline-note[An inline note that breaks the paragraph] 61 | #lorem(6) 62 | #inline-note(par-break: false)[A note with no paragraph break] 63 | #lorem(6) 64 | ``` 65 | 66 | #show-module-fn(module, "note-outline") 67 | ```example 68 | // Will show all (unhidden) notes in this manual 69 | #note-outline() 70 | ``` 71 | 72 | #show-module-fn(module, "rule-grid") 73 | -------------------------------------------------------------------------------- /docs/overview/main.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/docs/overview/main.pdf -------------------------------------------------------------------------------- /docs/overview/main.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/showman:0.1.2" 2 | #import "/drafting.typ" as drafting 3 | 4 | // TODO: Can't find how to tell pandoc the --root is at /, so it doesn't have access to typst.toml 5 | #let pkg-version = "0.2.2" 6 | // #let pkg-version = toml("/typst.toml").at("package").at("version") 7 | 8 | #let template(doc) = { 9 | show link: it => { 10 | set text(blue) 11 | underline(it) 12 | } 13 | set page( 14 | margin: (left: 1in, right: 2in, y: 0.25in), 15 | width: 6.5in, 16 | paper: "us-letter", 17 | height: auto, 18 | ) 19 | show : set text(font: "Libertinus Serif") 20 | drafting.set-page-properties() 21 | 22 | showman.formatter.template( 23 | // theme: "dark", 24 | eval-kwargs: (direction: ttb, scope: (drafting: drafting), unpack-modules: true), 25 | runnable-langs: ("example", "standalone"), 26 | doc, 27 | ) 28 | } 29 | #show: template 30 | // showman-config allows rendered examples during markdown export to have the 31 | // right page size 32 | #let showman-config = (template: template) 33 | 34 | = Margin Notes 35 | == Setup 36 | `drafting` exists in the official typst package repository, so the recommended approach is to import it from the `preview` namespace: 37 | #raw(lang: "typst", block: true, "#import \"@preview/drafting:" + pkg-version + "\"") 38 | 39 | Margin notes cannot lay themselves out correctly until they know your page size and margins. By default, they occupy nearly the entirety of the left or right margin, but you can provide explicit left/right bounds if desired: 40 | 41 | ```typ 42 | // Example: 43 | // Default margin in typst is 2.5cm, but we want to use 2cm 44 | // On the left 45 | #set-page-properties(margin-left: 2cm) 46 | ``` 47 | 48 | == The basics 49 | ```example 50 | #lorem(20) 51 | #margin-note(side: left)[Hello, world!] 52 | #lorem(10) 53 | #margin-note[Hello from the other side] 54 | #margin-note[When notes are about to overlap, they're automatically shifted] 55 | #margin-note(stroke: aqua + 3pt)[To avoid collision] 56 | #lorem(25) 57 | #margin-note(stroke: green, side: left)[You can provide two positional arguments if you want to highlight a phrase associated with your note.][The first is text which should be inline-noted, and the second is the standard margin note.] 58 | 59 | #let caution-rect = rect.with(inset: 1em, radius: 0.5em) 60 | #inline-note(rect: caution-rect, fill: orange.lighten(80%))[ 61 | Be aware that `typst` will complain when 4 notes overlap, and stop automatically avoiding collisions when 5 or more notes 62 | overlap. This is because the compiler stops attempting to reposition notes after a few attempts 63 | (initial layout + adjustment for each note). 64 | 65 | You can manually adjust the position of notes with `dy` to silence the warning. 66 | ] 67 | ``` 68 | 69 | == Adjusting the default style 70 | All function defaults are customizable through updating the module state: 71 | 72 | ```example 73 | #lorem(14) #margin-note[Default style] 74 | #lorem(10) 75 | #set-margin-note-defaults(stroke: orange, side: left) 76 | #margin-note[Updated style] 77 | #lorem(10) 78 | ``` 79 | 80 | Even deeper customization is possible by overriding the default `rect`: 81 | 82 | ```example 83 | #import "@preview/colorful-boxes:1.1.0": stickybox 84 | 85 | #let default-rect(stroke: none, fill: none, width: 0pt, content) = { 86 | set text(0.9em) 87 | stickybox(rotation: 30deg, width: width/1.5, content) 88 | } 89 | #set-margin-note-defaults(rect: default-rect, stroke: none, side: right) 90 | 91 | #lorem(20) 92 | #margin-note(dy: -5em)[Why not use sticky notes in the margin?] 93 | 94 | // Undo changes from this example 95 | #set-margin-note-defaults(rect: rect, stroke: red) 96 | ``` 97 | 98 | == Multiple document reviewers 99 | 100 | ```example 101 | #let reviewer-a = margin-note.with(stroke: blue) 102 | #let reviewer-b = margin-note.with(stroke: purple) 103 | #lorem(10) 104 | #reviewer-a[Comment from reviewer A] 105 | #lorem(5) 106 | #reviewer-b(side: left)[Reviewer B comment] 107 | #lorem(10) 108 | ``` 109 | 110 | == Inline Notes 111 | ```example 112 | #lorem(10) 113 | #inline-note[The default inline note will split the paragraph at its location] 114 | #lorem(10) 115 | #inline-note(par-break: false, stroke: (paint: orange, dash: "dashed"))[ 116 | But you can specify `par-break: false` to prevent this 117 | ] 118 | #lorem(10) 119 | ``` 120 | 121 | == Hiding notes for print preview 122 | 123 | ```example 124 | #set-margin-note-defaults(hidden: true) 125 | 126 | #lorem(20) 127 | #margin-note[This will respect the global "hidden" state] 128 | #margin-note(hidden: false, dy: -2.5em)[This note will never be hidden] 129 | // Undo these changes 130 | #set-margin-note-defaults(hidden: false) 131 | ``` 132 | 133 | = Outline of all notes 134 | ```example 135 | #note-outline() 136 | ``` 137 | 138 | = Link with indices insted of lines 139 | 140 | ```example 141 | #set-margin-note-defaults(link: "index") 142 | 143 | #lorem(20) 144 | #margin-note[This note is linked using its index] 145 | #margin-note[Maybe you prefer this look] 146 | 147 | 148 | #lorem(20) 149 | #set-margin-note-defaults(inline-numbering: "<1>", note-numbering: "a. ") 150 | #margin-note[You can also customize the numberings used] 151 | ``` 152 | 153 | = Positioning 154 | == Precise placement: rule grid 155 | Need to measure space for fine-tuned positioning? You can use `rule-grid` to cross-hatch 156 | the page with rule lines: 157 | 158 | ```example 159 | #rule-grid(width: 10cm, height: 3cm, spacing: 20pt) 160 | #place( 161 | dx: 180pt, 162 | dy: 40pt, 163 | rect(fill: white, stroke: red, width: 1in, "This will originate at (180pt, 40pt)") 164 | ) 165 | 166 | // Optionally specify divisions of the smallest dimension to automatically calculate 167 | // spacing 168 | #rule-grid(dx: 10cm + 3em, width: 3cm, height: 1.2cm, divisions: 5, square: true, stroke: green) 169 | 170 | // The rule grid doesn't take up space, so add it explicitly 171 | #v(3cm + 1em) 172 | ``` 173 | 174 | == Absolute positioning 175 | What about absolutely positioning something regardless of margin and relative location? `absolute-place` is your friend. You can put content anywhere: 176 | 177 | ```example 178 | #context { 179 | let (dx, dy) = (here().position().x, here().position().y) 180 | let content-str = ( 181 | "This absolutely-placed box will originate at (" + repr(dx) + ", " + repr(dy) + ")" 182 | + " in page coordinates" 183 | ) 184 | absolute-place( 185 | dx: dx, dy: dy, 186 | rect( 187 | fill: green.lighten(60%), 188 | radius: 0.5em, 189 | width: 2.5in, 190 | height: 0.5in, 191 | [#align(center + horizon, content-str)] 192 | ) 193 | ) 194 | } 195 | #v(0.5in) 196 | ``` 197 | 198 | The "rule-grid" also supports absolute placement at the top-left of the page by passing `relative: false`. This is helpful for "rule"-ing the whole page. 199 | -------------------------------------------------------------------------------- /docs/utils.typ: -------------------------------------------------------------------------------- 1 | #import "../drafting.typ" 2 | #let example-box = box.with(fill: white.darken(3%), inset: 0.5em, radius: 0.5em, width: 100%) 3 | 4 | 5 | #let dummy-page(width, height: auto, margin-left, margin-right, content) = ( 6 | context { 7 | let total-width = width + margin-left + margin-right 8 | let content-box = box( 9 | height: height, 10 | width: width, 11 | fill: white, 12 | stroke: (left: black + 0.5pt, right: black + 0.5pt), 13 | inset: 3pt, 14 | content, 15 | ) 16 | let box-height = measure(content-box).height 17 | let height = if height == auto { 18 | box-height 19 | } 20 | place(example-box(height: height, width: total-width, radius: 0pt)) 21 | pad( 22 | left: margin-left, 23 | content-box, 24 | ) 25 | } 26 | ) 27 | 28 | 29 | #let _build-preamble(scope) = { 30 | let preamble = "" 31 | for module in scope.keys() { 32 | preamble = preamble + "import " + module + ": *; " 33 | } 34 | preamble 35 | } 36 | 37 | #let eval-example(source, ..scope) = [ 38 | #let preamble = _build-preamble(scope.named()) 39 | #eval( 40 | (preamble + "[" + source + "]"), 41 | scope: scope.named(), 42 | ) 43 | 44 | ] 45 | 46 | #let _bidir-grid(direction, ..args) = { 47 | let grid-kwargs = (:) 48 | if direction == ltr { 49 | grid-kwargs = (columns: 2, column-gutter: 1em) 50 | } else { 51 | grid-kwargs = (rows: 2, row-gutter: 1em, columns: (100%,)) 52 | } 53 | grid(..grid-kwargs, ..args) 54 | } 55 | 56 | #let example-with-source(source, inline: false, direction: ttb, ..scope) = { 57 | let picture = eval-example(source, ..scope) 58 | let source-box = if inline { 59 | box 60 | } else { 61 | block 62 | } 63 | 64 | _bidir-grid(direction)[ 65 | #example-box(raw(lang: "typ", source)) 66 | ][ 67 | #example-box(picture) 68 | ] 69 | 70 | } 71 | 72 | 73 | #let _make-page(source, offset, w, l, r, scope) = { 74 | let props = ( 75 | "margin-right:" + repr(r) + ", margin-left:" + repr(l) + ", page-width:" + repr(w) + ", page-offset-x: " + repr(offset) 76 | ) 77 | let preamble = "#let margin-note = margin-note.with(" + props + ")\n" 78 | let content = eval-example(preamble + source.text, ..scope) 79 | dummy-page(w, l, r, content) 80 | } 81 | 82 | #let standalone-margin-note-example( 83 | source, 84 | width: 2in, 85 | margin-left: 0.8in, 86 | margin-right: 1in, 87 | scope: (drafting: drafting), 88 | direction: ltr, 89 | ) = { 90 | let (l, r) = (margin-left, margin-right) 91 | let number-args = (width, l, r) 92 | let content = _make-page(source, 0pt, ..number-args, scope) 93 | _bidir-grid( 94 | direction, 95 | example-box(width: 100%, source), 96 | context { 97 | let offset = here().position().x 98 | set text(font: "Libertinus Serif") 99 | layout(layout-size => { 100 | let minipage = content 101 | let minipage-size = measure(minipage) 102 | let (width, height) = (minipage-size.width, minipage-size.height) 103 | let ratio = (layout-size.width / width) * 100% 104 | let w = width 105 | let number-args = number-args.map(n => n * ratio) 106 | minipage = _make-page(source, offset, ..number-args, scope) 107 | minipage 108 | }) 109 | }, 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /drafting.typ: -------------------------------------------------------------------------------- 1 | /// Default properties for margin notes. These can be overridden per function call, or 2 | /// globally by calling `set-margin-note-defaults`. Available options are: 3 | /// - `margin-right` (length): Size of the right margin 4 | /// - `margin-left` (length): Size of the left margin 5 | /// - `margin-inside` (length): Size of the inside margin 6 | /// - `margin-outside` (length): Size of the outside margin 7 | /// - `page-binding` (auto | alignment): Where the page is bound 8 | /// - `page-width` (length): Width of the page minus the margins. 9 | /// This is automatically inferrable when using `set-page-properties` 10 | /// - `page-offset-x` (length): Horizontal offset of the page/container. This is 11 | /// generally only useful if margin notes are applied inside a box/rect; at the page 12 | /// level this can remain unspecified 13 | /// - `stroke` (paint): Stroke to use for the margin note's border and connecting line 14 | /// - `fill` (paint): Background to use for the note 15 | /// - `rect` (function): Function to use for drawing the margin note's border. This 16 | /// function must accept positional `content` and keyword `width` arguments. 17 | /// - `side` (side): Which side of the page to place the margin note on. Must be `left` 18 | /// or `right` 19 | /// - `hidden` (bool): Whether to hide the margin note. This is useful for temporarily 20 | /// disabling margin notes without removing them from the code 21 | /// - `caret-height` (length): Size of the caret from the text baseline 22 | /// - `link` ("line" | "index"): Whether to link the origin of the note to the note 23 | /// margin itself with a link or with an index. 24 | /// - `inline-numbering` (numbering | auto): The numbering shown in the text inlined. 25 | /// Doesn't do anything unless `link` is set to `"index"` 26 | /// - `note-numbering` (numbering | auto): The numbering shown in the margin note. 27 | /// -> dictionary 28 | #let margin-note-defaults = state( 29 | "margin-note-defaults", 30 | ( 31 | margin-right: none, 32 | margin-left: none, 33 | margin-inside: none, 34 | margin-outside: none, 35 | page-binding: auto, 36 | page-width: none, 37 | page-offset-x: 0in, 38 | stroke: red, 39 | fill: none, 40 | rect: rect, 41 | side: auto, 42 | hidden: false, 43 | caret-height: 1em, 44 | link: "line", 45 | inline-numbering: auto, 46 | note-numbering: auto, 47 | ) 48 | ) 49 | #let note-descent = state("note-descent", (:)) 50 | 51 | /// Place content at a specific location on the page relative to the top left corner 52 | /// of the page, regardless of margins, current container, etc. 53 | /// -> content 54 | #let absolute-place(dx: 0em, dy: 0em, content) = { 55 | [#metadata("absolute-place")] 56 | context { 57 | let dx = measure(h(dx)).width 58 | let dy = measure(v(dy)).height 59 | context { 60 | let pos = query().last().location().position() 61 | place(dx: -pos.x + dx, dy: -pos.y + dy, content) 62 | } 63 | } 64 | } 65 | 66 | #let _calc-text-resize-ratio(width, spacing) = { 67 | // Add extra margin to ensure reasonable separation between two adjacent lines 68 | let size = measure(text[#width]).width * 120% 69 | spacing / size * 100% 70 | } 71 | 72 | 73 | /// Add a series of evenly spaced x- any y-axis lines to the page. -> content 74 | #let rule-grid( 75 | /// Horizontal offset from the top left corner of the page -> length 76 | dx: 0pt, 77 | /// Vertical offset from the top left corner of the page -> length 78 | dy: 0pt, 79 | /// Stroke to use for the grid lines. The `paint` of this stroke will determine the 80 | /// text color. 81 | /// -> paint 82 | stroke: black, 83 | /// Total width of the grid -> length 84 | width: 100cm, 85 | /// Total height of the grid -> length 86 | height: 100cm, 87 | /// Spacing between grid lines. If an array is provided, the values are taken in (x, y) 88 | /// order. Cannot be provided alongside `divisions`. 89 | /// -> length, array 90 | spacing: none, 91 | /// Number of divisions along each axis. If an array is provided, the values are taken 92 | /// in (x, y) order. Cannot be provided alongside `spacing`. 93 | /// -> int, array 94 | divisions: none, 95 | /// Whether to make the grid square. If `true`, and either `divisions` or `spacing` is 96 | /// provided, the smaller of the two values will be used for both axes to ensure each 97 | /// grid cell is square. 98 | /// -> bool 99 | square: false, 100 | /// If `true` (default), the grid will be placed relative to the current container. If 101 | /// `false`, the grid will be placed relative to the top left corner of the page. 102 | /// -> bool 103 | relative: true, 104 | ) = { 105 | // Unfortunately an int cannot be constructed from a length, so get it through a 106 | // hacky method of converting to a string then an int 107 | if spacing == none and divisions == none { 108 | panic("Either `spacing` or `divisions` must be specified") 109 | } 110 | if spacing != none and divisions != none { 111 | panic("Only one of `spacing` or `divisions` can be specified") 112 | } 113 | if divisions != none and type(divisions) != array { 114 | divisions = (divisions, divisions) 115 | } 116 | if spacing != none and type(spacing) != array { 117 | spacing = (spacing, spacing) 118 | } 119 | 120 | let place-func = if relative { 121 | place 122 | } else { 123 | absolute-place 124 | } 125 | let global-dx = dx 126 | let global-dy = dy 127 | let to-int(amt) = int(amt.abs / 1pt) 128 | let to-pt(amt) = amt * 1pt 129 | 130 | let (divisions, spacing) = (divisions, spacing) 131 | 132 | if divisions != none { 133 | divisions = divisions.map(to-pt) 134 | spacing = (width / divisions.at(0), height / divisions.at(1)) 135 | if square { 136 | spacing = (calc.min(..spacing), calc.min(..spacing)) 137 | } 138 | spacing = spacing.map(to-pt) 139 | } 140 | let (x-spacing, y-spacing) = spacing 141 | 142 | let (width, height, step) = (width, height, x-spacing).map(to-int) 143 | // Assume text width is the limiting factor since a number will often be wider than 144 | // tall. This works in the majority of cases 145 | context { 146 | let scale-factor = _calc-text-resize-ratio(width, x-spacing) 147 | 148 | set line(stroke: stroke) 149 | let dummy-line = line(stroke: stroke) 150 | set text(size: 1em * scale-factor, fill: dummy-line.stroke.paint) 151 | for (ii, dx) in range(0, width, step: step).enumerate() { 152 | place-func( 153 | dx: global-dx, 154 | dy: global-dy, 155 | line(start: (dx * 1pt, 0pt), end: (dx * 1pt, height * 1pt)), 156 | ) 157 | place-func( 158 | dx: global-dx + (dx * 1pt), 159 | dy: global-dy, 160 | repr(ii * step), 161 | ) 162 | } 163 | let step = to-int(y-spacing) 164 | for (ii, dy) in range(0, height, step: step).enumerate() { 165 | place-func( 166 | dx: global-dx, 167 | dy: global-dy, 168 | line(start: (0pt, dy * 1pt), end: (width * 1pt, dy * 1pt)), 169 | ) 170 | place-func( 171 | dy: global-dy + dy * 1pt, 172 | dx: global-dx, 173 | repr(ii * step), 174 | ) 175 | } 176 | } 177 | } 178 | 179 | /// Changes the default properties for margin notes. See documentation on 180 | /// `margin-note-defaults` for available options. 181 | /// -> any 182 | #let set-margin-note-defaults(..defaults) = { 183 | defaults = defaults.named() 184 | margin-note-defaults.update(old => { 185 | if type(old) != dictionary { 186 | panic("margin-note-defaults must be a dictionary") 187 | } 188 | if (old + defaults).len() != old.len() { 189 | let allowed-keys = array(old.keys()) 190 | let violators = array(defaults.keys()).filter(key => key not in allowed-keys) 191 | panic( 192 | "margin-note-defaults can only contain the following keys: " 193 | + allowed-keys.join(", ") 194 | + ". Got: " 195 | + violators.join(", "), 196 | ) 197 | } 198 | old + defaults 199 | }) 200 | } 201 | 202 | /// Place a rectangle in the margin of the page. Useful for debugging spacing. 203 | /// -> content 204 | #let place-margin-rects( 205 | /// amount of padding to add to the left and right of the rectangles -> length 206 | padding: 1%, 207 | /// Additional properties to apply to the rectangles -> any 208 | ..rect-kwargs, 209 | ) = { 210 | let rect-kwargs = rect-kwargs.named() 211 | if "height" not in rect-kwargs { 212 | rect-kwargs.insert("height", 100%) 213 | } 214 | context { 215 | let props = margin-note-defaults.get() 216 | let (page-width, r-width, l-width) = ( 217 | props.page-width, 218 | props.margin-right, 219 | props.margin-left, 220 | ) 221 | let r(w) = rect(width: w, ..rect-kwargs) 222 | absolute-place(r(l-width - padding)) 223 | absolute-place(dx: page-width + l-width + padding, r(r-width - padding)) 224 | } 225 | } 226 | 227 | /// get direction of text based on defaults for the language 228 | /// https://github.com/typst/typst/blob/521ceae889f15f2a93683ab776cd86a423e5dbed/crates/typst-library/src/text/lang.rs#L109 229 | /// -> text-direction 230 | #let text-direction(dir, lang) = if dir == auto { 231 | if ( 232 | lang 233 | in ( 234 | "ar", 235 | "dv", 236 | "fa", 237 | "he", 238 | "ks", 239 | "pa", 240 | "ps", 241 | "sd", 242 | "ug", 243 | "ur", 244 | "yi", 245 | ) 246 | ) { rtl } else { ltr } 247 | } else { 248 | dir 249 | } 250 | 251 | /// get margins of page 252 | #let _get-margins() = { 253 | let margin = ( 254 | left: none, 255 | right: none, 256 | inside: none, 257 | outside: none, 258 | ) 259 | if type(page.margin) != dictionary { 260 | margin.left = page.margin 261 | margin.right = page.margin 262 | } else { 263 | if "right" in page.margin.keys() { 264 | margin.right = page.margin.right 265 | margin.left = page.margin.left 266 | } else if "inside" in page.margin.keys() { 267 | margin.inside = page.margin.inside 268 | margin.outside = page.margin.outside 269 | } 270 | } 271 | 272 | if (page.width, page.height) == (auto, auto) { 273 | if ("right" in margin.keys() and auto in (margin.left, margin.right)) { 274 | panic( 275 | "If the page width *and* height are set to `auto`, neither left nor right margin" 276 | + " can be `auto`. Got (left, right) margin " 277 | + repr(( 278 | margin.left, 279 | margin.right, 280 | )) 281 | + ", and page (width, height) " 282 | + repr((page.width, page.height)), 283 | ) 284 | } 285 | if ("inside" in margin.keys() and auto in (margin.inside, margin.outside)) { 286 | panic( 287 | "If the page width *and* height are set to `auto`, neither inside nor outside margin" 288 | + " can be `auto`. Got (inside, outside) margin " 289 | + repr(( 290 | margin.inside, 291 | margin.outside, 292 | )) 293 | + ", and page (width, height) " 294 | + repr((page.width, page.height)), 295 | ) 296 | } 297 | } 298 | // https://github.com/typst/typst/issues/3636#issuecomment-1992541661 299 | let page-dims = (page.width, page.height).filter(x => x != auto) 300 | let auto-size = 2.5 / 21 * calc.min(..page-dims) 301 | 302 | let defaults = ( 303 | margin-left: margin.left, 304 | margin-right: margin.right, 305 | margin-inside: margin.inside, 306 | margin-outside: margin.outside, 307 | ) 308 | for (k, v) in defaults { 309 | if v == auto { 310 | defaults.at(k) = auto-size 311 | } else if type(v) == relative { 312 | defaults.at(k) = v.length 313 | } // ignore if none 314 | } 315 | 316 | return defaults 317 | } 318 | 319 | 320 | /// Required for `margin-note` to work, since it informs `drafting` of the page setup. 321 | #let set-page-properties(..kwargs) = { 322 | show: place // Avoid extra whitespace 323 | context { 324 | let kwargs = kwargs.named() 325 | let margins = _get-margins() 326 | 327 | let binding = if page.binding == auto { 328 | if text-direction(text.dir, text.lang) == ltr { 329 | left 330 | } else { right } 331 | } else { 332 | page.binding 333 | } 334 | 335 | let combined-margin-w = if margins.margin-left != none { 336 | margins.margin-left + margins.margin-right 337 | } else { 338 | margins.margin-inside + margins.margin-outside 339 | } 340 | 341 | set-margin-note-defaults( 342 | ..margins, 343 | page-width: page.width - combined-margin-w, 344 | page-binding: binding, 345 | ..kwargs, 346 | ) 347 | } 348 | } 349 | 350 | 351 | // Credit: copied from t4t package to avoid required dependency 352 | #let get-text(element, sep: "") = { 353 | if type(element) == content { 354 | if element.has("text") { 355 | element.text 356 | } else if element.has("children") { 357 | element.children.map(get-text).join(sep) 358 | } else if element.has("child") { 359 | get-text(element.child) 360 | } else if element.has("body") { 361 | get-text(element.body) 362 | } else if repr(element.func()) == "space" { 363 | " " 364 | } else { 365 | "" 366 | } 367 | } else if type(element) in (array, dictionary) { 368 | return "" 369 | } else { 370 | str(element) 371 | } 372 | } 373 | 374 | 375 | #let _get-note-outline-props(note) = { 376 | let func = note.func() 377 | let defaults = margin-note-defaults.get() 378 | // styled types from custom notes have unknown fill/stroke/body 379 | // TODO: Maybe a better way to signify "unknown"? 380 | let props = ( 381 | fill: note.at("fill", default: defaults.fill), 382 | stroke: note.at("stroke", default: defaults.stroke), 383 | // if we do not use get-text formatting is included (font size, color, footnotes, etc.) 384 | body: get-text(note.at("body", default: "")).trim(), 385 | ) 386 | 387 | return props 388 | } 389 | 390 | /// Show an outline of all notes -> content 391 | #let note-outline( 392 | /// Title of the outline -> string 393 | title: "List of Notes", 394 | /// Level of the heading -> number 395 | level: 1, 396 | /// Spacing between outline elements -> relative 397 | row-gutter: 10pt, 398 | ) = context { 399 | heading(level: level, title) 400 | 401 | let notes = query(selector().or()).map(note => { 402 | show: box // do not break entries across pages 403 | let note-props = _get-note-outline-props(note) 404 | let paint = if note-props.stroke != none { stroke(note-props.stroke).paint } else { none } 405 | link( 406 | note.location().position(), 407 | grid( 408 | columns: (1em, 1fr, 10pt), 409 | column-gutter: 5pt, 410 | align: (top, bottom, bottom), 411 | box( 412 | fill: note-props.fill, 413 | stroke: if note-props.stroke == none { 414 | none 415 | } else if paint != note-props.fill { 416 | paint 417 | } else { 418 | black + .5pt 419 | }, 420 | width: 1em - 2pt, 421 | height: 1em - 2pt, 422 | ), 423 | [#note-props.body #box(width: 1fr, repeat[.])], 424 | [#note.location().page()], 425 | ), 426 | ) 427 | }) 428 | 429 | grid( 430 | row-gutter: row-gutter, 431 | ..notes 432 | ) 433 | } 434 | 435 | /// Place a note inline with the text body. 436 | /// -> content 437 | #let inline-note( 438 | /// Margin note contents, usually text -> content 439 | body, 440 | /// Whether to break the paragraph after the note, which places the note on its own line. 441 | /// -> bool 442 | par-break: true, 443 | /// Additional properties to apply to the note. Accepted values are keys from 444 | /// `margin-note-defaults`. 445 | /// -> any 446 | ..kwargs, 447 | ) = { 448 | context { 449 | let properties = margin-note-defaults.get() + kwargs.named() 450 | if properties.hidden { 451 | return 452 | } 453 | 454 | let rect-func = properties.at("rect") 455 | if par-break { 456 | return [ 457 | #rect-func(body, stroke: properties.stroke, fill: properties.fill) 458 | ] 459 | } 460 | // else 461 | let s = none 462 | let dummy-rect = rect-func(stroke: properties.stroke, fill: properties.fill)[dummy content] 463 | let default-rect = rect(stroke: properties.stroke, fill: properties.fill)[dummy content] 464 | if "stroke" in dummy-rect.fields() { 465 | s = dummy-rect.stroke 466 | } else { 467 | s = default-rect.stroke 468 | } 469 | let bottom = 0.35em 470 | let top = 1em 471 | set text(top-edge: "ascender", bottom-edge: "descender") 472 | let cap-line = { 473 | let t = if s.thickness == auto { 474 | 0pt 475 | } else { 476 | s.thickness / 2 477 | } 478 | box(height: top, outset: (bottom: bottom + t, top: t), stroke: (left: properties.stroke), fill: properties.fill) 479 | } 480 | let new-body = underline(stroke: properties.stroke, [ #body ], offset: bottom) 481 | if dummy-rect.has("fill") and dummy-rect.fill != auto { 482 | new-body = highlight(new-body, fill: dummy-rect.fill) 483 | } 484 | new-body = [ 485 | #underline([#cap-line#new-body#cap-line], stroke: properties.stroke, offset: -top) 486 | 487 | ] 488 | new-body 489 | } 490 | } 491 | 492 | #let margin-lines(stroke: gray + 0.5pt) = { 493 | context { 494 | let r-margin = margin-note-defaults.get().margin-right 495 | let l-margin = margin-note-defaults.get().margin-left 496 | place(dx: -2%, rect(height: 100%, width: 104%, stroke: (left: stroke, right: stroke))) 497 | 498 | // absolute-place(dx: 100% - l-margin, line(end: (0%, 100%))) 499 | } 500 | } 501 | 502 | #let _path-from-diffs(start: (0pt, 0pt), ..diffs) = { 503 | let diffs = diffs.pos() 504 | let out-path = (start,) 505 | let next-pt = start 506 | for diff in diffs { 507 | next-pt = (next-pt.at(0) + diff.at(0), next-pt.at(1) + diff.at(1)) 508 | out-path.push(next-pt) 509 | } 510 | out-path 511 | } 512 | 513 | #let _get-page-pct(props) = { 514 | let page-width = props.page-width 515 | if page-width == none { 516 | panic("drafting's default `page-width` must be specified and non-zero before creating a note") 517 | } 518 | page-width / 100 519 | } 520 | 521 | #let _get-current-descent(descents-dict, page-number: auto) = { 522 | if page-number == auto { 523 | page-number = descents-dict.keys().at(-1, default: "0") 524 | } else { 525 | page-number = str(page-number) 526 | } 527 | (page-number, descents-dict.at(page-number, default: (left: 0pt, right: 0pt))) 528 | } 529 | 530 | #let _update-descent(side, dy, anchor-y, note-rect, page-number) = { 531 | context { 532 | let height = measure(note-rect).height 533 | let dy = measure(v(dy + height)).height + anchor-y 534 | note-descent.update(old => { 535 | let (cnt, props) = _get-current-descent(old, page-number: page-number) 536 | props.insert(side, calc.max(dy, props.at(side))) 537 | old.insert(cnt, props) 538 | old 539 | }) 540 | } 541 | } 542 | 543 | #let margin-note-counter = counter("margin-notes") 544 | 545 | #let _margin-note-right(body, dy, anchor-x, anchor-y, ..props) = { 546 | props = props.named() 547 | let pct = _get-page-pct(props) 548 | let dist-to-margin = 101 * pct - anchor-x + props.margin-left 549 | let text-offset = 0.5em 550 | let right-width = props.margin-right - 4 * pct 551 | 552 | let link = if props.link == "line" { 553 | let path-pts = ( 554 | // make an upward line before coming back down to go all the way to 555 | // the top of the lettering 556 | (0pt, -props.caret-height), 557 | (0pt, props.caret-height + text-offset), 558 | (dist-to-margin, 0pt), 559 | (0pt, dy), 560 | (1 * pct + right-width / 2, 0pt), 561 | ) 562 | 563 | // Boxing prevents forced paragraph breaks 564 | let moves = path-pts.map(pt => curve.line(pt, relative: true)) 565 | 566 | dy += text-offset 567 | place(curve(stroke: props.stroke, ..moves)) 568 | } else { 569 | let numbering = if props.inline-numbering == auto { 570 | i => super([*\[#i\]*]) 571 | } else { 572 | props.inline-numbering 573 | } 574 | 575 | text(fill: props.stroke, margin-note-counter.display(numbering)) 576 | } 577 | 578 | let note-rect = (props.rect)( 579 | stroke: props.stroke, 580 | fill: props.fill, 581 | width: right-width, 582 | body, 583 | ) 584 | 585 | box[ 586 | #link 587 | #place(dx: dist-to-margin + 1 * pct, dy: dy, [#note-rect]) 588 | ] 589 | _update-descent("right", dy, anchor-y, note-rect, here().page()) 590 | } 591 | 592 | 593 | #let _margin-note-left(body, dy, anchor-x, anchor-y, ..props) = { 594 | props = props.named() 595 | let pct = _get-page-pct(props) 596 | let dist-to-margin = -anchor-x + 1 * pct 597 | let text-offset = 0.4em 598 | let box-width = props.margin-left - 4 * pct 599 | 600 | let link = if props.link == "line" { 601 | let path-pts = ( 602 | (0pt, -props.caret-height), 603 | (0pt, props.caret-height + text-offset), 604 | (-anchor-x + props.margin-left + 1 * pct, 0pt), 605 | (-2 * pct, 0pt), 606 | (0pt, dy), 607 | (-1 * pct - box-width / 2, 0pt), 608 | ) 609 | dy += text-offset 610 | 611 | // Boxing prevents forced paragraph breaks 612 | let moves = path-pts.map(pt => curve.line(pt, relative: true)) 613 | 614 | place(curve(stroke: props.stroke, ..moves)) 615 | } else { 616 | let numbering = if props.inline-numbering == auto { 617 | i => super([*\[#i\]*]) 618 | } else { 619 | props.inline-numbering 620 | } 621 | 622 | text(fill: props.stroke, margin-note-counter.display(numbering)) 623 | } 624 | 625 | let note-rect = props.at("rect")( 626 | stroke: props.stroke, 627 | fill: props.fill, 628 | width: box-width, 629 | body, 630 | ) 631 | box[ 632 | #link 633 | #place(dx: dist-to-margin + 1 * pct, dy: dy, [#note-rect]) 634 | ] 635 | _update-descent("left", dy, anchor-y, note-rect, here().page()) 636 | } 637 | 638 | 639 | /// Places a boxed note in the left or right page margin. -> content 640 | #let margin-note( 641 | /// Margin note contents, usually text -> content 642 | body, 643 | /// Vertical offset from the note's location -- negative values move the note up, positive values move the note down 644 | /// -> length 645 | dy: auto, 646 | /// Additional properties to apply to the note. Accepted values are keys from `margin-note-defaults`. 647 | /// -> any 648 | ..kwargs, 649 | ) = { 650 | margin-note-counter.step() 651 | // h(0pt) forces here().position() to take paragraph indent into account 652 | h(0pt) 653 | let phrase = none 654 | if kwargs.pos().len() > 0 { 655 | (phrase, body) = (body, kwargs.pos().at(0)) 656 | } 657 | if phrase != none { 658 | inline-note(phrase, par-break: false, ..kwargs.named()) 659 | } 660 | context { 661 | let defaults = margin-note-defaults.get() 662 | if ( 663 | defaults.page-width == none 664 | or ( 665 | none in (defaults.margin-left, defaults.margin-right) 666 | and none in (defaults.margin-inside, defaults.margin-outside) 667 | ) 668 | ) { 669 | // `box` allows this call to be in the same paragraph context as the noted text 670 | show: box 671 | set-page-properties() 672 | } 673 | } 674 | context { 675 | let pos = here().position() 676 | let properties = margin-note-defaults.get() + kwargs.named() 677 | let (anchor-x, anchor-y) = (pos.x - properties.page-offset-x, pos.y) 678 | 679 | if properties.hidden { 680 | return 681 | } 682 | 683 | // Overwrite the properties for left / right margins 684 | // This way we only need to calculate this once 685 | let m = _get-margins() 686 | if m.margin-inside != none { 687 | if calc.odd(pos.page) == (properties.page-binding == left) { 688 | properties.at("margin-left") = m.margin-inside 689 | properties.at("margin-right") = m.margin-outside 690 | } else { 691 | properties.at("margin-left") = m.margin-outside 692 | properties.at("margin-right") = m.margin-inside 693 | } 694 | } 695 | 696 | for k in ("margin-right", "margin-left", "page-width") { 697 | if k not in properties or properties.at(k) == none { 698 | panic("margin-note can only be called after `set-page-properties`, or when margins + page size are explicitly set.") 699 | } 700 | } 701 | if properties.side == auto { 702 | let (r, l) = (properties.margin-right, properties.margin-left) 703 | properties.side = if calc.max(r, l) == r { 704 | right 705 | } else { 706 | left 707 | } 708 | } 709 | 710 | // `let` assignment allows mutating argument 711 | let dy = dy 712 | if dy == auto { 713 | let (cur-page, descents) = _get-current-descent(note-descent.get(), page-number: here().page()) 714 | let cur-descent = descents.at(repr(properties.side)) 715 | dy = calc.max(0pt, cur-descent - pos.y) 716 | // Notes at the end of a line misreport their x position, the placed box will wrap 717 | // onto the next line and invalidate the calculated distance. 718 | // A hacky fix is to manually replace the x position to an offset of 0. 719 | 720 | let is-end-of-line = ( 721 | calc.abs(anchor-x - properties.margin-left - properties.page-width - properties.page-offset-x) < 0.1pt 722 | ) 723 | if is-end-of-line { 724 | anchor-x -= properties.page-width 725 | } 726 | } 727 | 728 | let note-numbering = if properties.note-numbering == auto { 729 | // By default, show note numbering only when using indices to link. 730 | if properties.link == "index" { i => [*#i*: ] } else { i => [] } 731 | } else { 732 | properties.note-numbering 733 | } 734 | let prefix = margin-note-counter.display(note-numbering) 735 | 736 | let margin-func = if properties.side == right { 737 | _margin-note-right 738 | } else { 739 | _margin-note-left 740 | } 741 | margin-func(prefix + body, dy, anchor-x, anchor-y, ..properties) 742 | } 743 | } 744 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1743550720, 9 | "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "c621e8422220273271f52058f618c94e405bb0f5", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1746152631, 24 | "narHash": "sha256-zBuvmL6+CUsk2J8GINpyy8Hs1Zp4PP6iBWSmZ4SCQ/s=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "032bc6539bd5f14e9d0c51bd79cfe9a055b094c3", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs-lib": { 38 | "locked": { 39 | "lastModified": 1743296961, 40 | "narHash": "sha256-b1EdN3cULCqtorQ4QeWgLMrd5ZGOjLSLemfa00heasc=", 41 | "owner": "nix-community", 42 | "repo": "nixpkgs.lib", 43 | "rev": "e4822aea2a6d1cdd36653c134cacfd64c97ff4fa", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "nix-community", 48 | "repo": "nixpkgs.lib", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "flake-parts": "flake-parts", 55 | "nixpkgs": "nixpkgs" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 | flake-parts.url = "github:hercules-ci/flake-parts"; 5 | }; 6 | 7 | outputs = 8 | inputs@{ 9 | nixpkgs, 10 | flake-parts, 11 | ... 12 | }: 13 | flake-parts.lib.mkFlake { inherit inputs; } { 14 | systems = [ 15 | "x86_64-linux" 16 | "aarch64-linux" 17 | "x86_64-darwin" 18 | "aarch64-darwin" 19 | ]; 20 | perSystem = 21 | { 22 | pkgs, 23 | ... 24 | }: 25 | { 26 | formatter = pkgs.nixfmt-rfc-style; 27 | 28 | devShells.default = pkgs.mkShell { 29 | buildInputs = [ 30 | pkgs.typst 31 | pkgs.tinymist 32 | pkgs.typstyle 33 | ]; 34 | }; 35 | }; 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /package.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import typing as t 3 | from pathlib import Path 4 | 5 | from packaging.version import InvalidVersion 6 | from packaging.version import Version as PkgVersion 7 | import tomli 8 | 9 | FilePath = t.Union[str, Path] 10 | 11 | 12 | def create_package( 13 | source_folder: FilePath, 14 | typst_packages_folder: FilePath, 15 | package_paths: t.List[str | FilePath], 16 | version: str, 17 | package_name: str, 18 | namespace="preview", 19 | exist_ok=False, 20 | ): 21 | try: 22 | PkgVersion(version) 23 | except InvalidVersion: 24 | raise ValueError(f"{version} is not a valid version") 25 | 26 | upload_folder = Path(typst_packages_folder) / namespace / package_name / version 27 | if upload_folder.exists() and not exist_ok: 28 | raise FileExistsError(f"{upload_folder} already exists") 29 | elif upload_folder.exists(): 30 | shutil.rmtree(upload_folder) 31 | upload_folder.mkdir(parents=True) 32 | 33 | src = Path(source_folder) 34 | for path in map(Path, package_paths): 35 | if path.is_dir(): 36 | shutil.copytree( 37 | src.joinpath(path), upload_folder.joinpath(path), dirs_exist_ok=True 38 | ) 39 | else: 40 | shutil.copy(src.joinpath(path), upload_folder.joinpath(path)) 41 | return upload_folder 42 | 43 | 44 | if "__main__" == __name__: 45 | import argparse 46 | import os 47 | 48 | here = Path(__file__).resolve().parent 49 | default_packages_folder = os.environ.get("typst_packages_folder", None) 50 | 51 | parser = argparse.ArgumentParser() 52 | parser.add_argument("toml", help="path to typst.toml", default=here / "typst.toml") 53 | parser.add_argument("--namespace", default="preview") 54 | parser.add_argument("--exist-ok", action="store_true") 55 | parser.add_argument("--typst-packages-folder", default=default_packages_folder) 56 | args = parser.parse_args() 57 | 58 | toml_file = Path(args.toml).resolve() 59 | with open(toml_file, "rb") as ifile: 60 | toml_text = tomli.load(ifile) # type: ignore 61 | version = toml_text["package"]["version"] 62 | package_name = toml_text["package"]["name"] 63 | package_paths = toml_text["tool"]["packager"]["paths"] 64 | package_paths.append(toml_file.name) 65 | 66 | folder = create_package( 67 | Path(args.toml).resolve().parent, 68 | args.typst_packages_folder, 69 | package_paths, 70 | version, 71 | package_name, 72 | namespace=args.namespace, 73 | exist_ok=args.exist_ok, 74 | ) 75 | print(f"Created package in {folder}") 76 | -------------------------------------------------------------------------------- /release-instructions.md: -------------------------------------------------------------------------------- 1 | # Ensure `typst.toml` is up to date 2 | - Bump `version` 3 | - Ensure no new filepaths are needed in `tool.packager` 4 | 5 | # Ensure Typst readme is up to date 6 | - Examples for new functionality present in [overview.typ](./docs/overview/main.typ) 7 | - Compile overview to PDF and ensure everything looks good 8 | 9 | # Update generated readme 10 | - Ensure `showman` is installed: 11 | ```sh 12 | pip install showman 13 | ``` 14 | - Comment out `--git_url` in `update_readme.sh`, then run it: 15 | ```sh 16 | ./update_readme.sh 17 | ``` 18 | You should see `readme.md` update, and you can verify the images look good. 19 | - Uncomment `--git_url` in `update_readme.sh`, change the referenced tag to the new version, then run the script again. This wil replace each image reference in the readme with a link to GitHub assets, meaning we don't have to package the images with the release. 20 | - Add all updates to git: 21 | ```sh 22 | git add . 23 | git commit -m "Update readme for release" 24 | ``` 25 | - Create a PR branch to `typst-drafting` 26 | - Push the updates to this branch 27 | - Once the PR is merged, tag the main repo head with the new version: 28 | ```sh 29 | git tag vX.Y.Z 30 | git push --tags 31 | ``` 32 | 33 | # Create a new release 34 | - Clone [`typst/packages`](https://github.com/typst/packages) locally 35 | - Inside `typst/packages`, create a new branch for your changes 36 | - Run showman to bundle assets into the new release: 37 | ```sh 38 | showman package ./typst.toml \ 39 | --typst_packages_folder /path/to/typst/packages/packages/ \ 40 | --namespace preview \ 41 | --overwrite 42 | ``` 43 | 44 | > [!NOTE] 45 | > The `typst_packages_folder` is what holds the `preview` folder, not the repo root. So make sure it's `packages/packages/` (which contains `preview`) and not just `packages/`. 46 | 47 | - Submit a PR upstream to typst/packages 48 | 49 | 🎉 You're done! The new release should be available in the Typst universe. 50 | -------------------------------------------------------------------------------- /tests/columns.typ: -------------------------------------------------------------------------------- 1 | #import "../drafting.typ": * 2 | 3 | #set page(margin: (x: 5cm), width: 25cm, height: 10cm) 4 | 5 | #let l = margin-note(side: left)[Specified left] 6 | #let r = margin-note(side: right, stroke: green)[Specified right] 7 | #let a = margin-note(stroke: blue)[Automatically placed] 8 | 9 | 10 | #set page(columns: 2) 11 | = 2 Page columns 12 | #lorem(10) 13 | #l 14 | #r 15 | #a 16 | 17 | #lorem(150) 18 | #l 19 | #r 20 | #a 21 | 22 | 23 | #set page(columns: 3) 24 | = 3 Page columns 25 | #lorem(10) 26 | #l 27 | #r 28 | #a 29 | 30 | #lorem(60) 31 | #l 32 | #r 33 | #a 34 | 35 | #lorem(80) 36 | #l 37 | #r 38 | #a 39 | 40 | #pagebreak() 41 | #set page(columns: 4) 42 | = 4 Page columns 43 | #lorem(1) 44 | #l 45 | #r 46 | #a 47 | 48 | #lorem(40) 49 | #l 50 | #r 51 | #a 52 | 53 | #lorem(52) 54 | #l 55 | #r 56 | #a 57 | 58 | #lorem(50) 59 | #l 60 | #r 61 | #a 62 | 63 | #pagebreak() 64 | #set page(columns: 1) 65 | #show: columns.with(2) 66 | = Columns function 67 | #lorem(10) 68 | #l 69 | #r 70 | #a 71 | 72 | #lorem(150) 73 | #l 74 | #r 75 | #a 76 | 77 | 78 | #colbreak() 79 | = End of line break --- bug 80 | #lorem(30) 81 | #l 82 | #r 83 | #a 84 | 85 | #colbreak() 86 | #lorem(30) 87 | #r 88 | -------------------------------------------------------------------------------- /tests/link-type.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-drafting/4592eefc1f5dd7cc30e942a629ce8df695634ecd/tests/link-type.pdf -------------------------------------------------------------------------------- /tests/link-type.typ: -------------------------------------------------------------------------------- 1 | #set page(margin: (x: 5cm), width: 25cm, height: 10cm) 2 | 3 | #import "../drafting.typ": * 4 | 5 | #set-margin-note-defaults(link: "index") 6 | 7 | = `link = "index"` 8 | #lorem(40) 9 | #margin-note[Hello world] 10 | #margin-note[Stacked] 11 | #lorem(40) 12 | 13 | == Custom numbering 14 | #set-margin-note-defaults(inline-numbering: i => [*<#i>*], note-numbering: i => [*TODO #i:* ]) 15 | #margin-note[#lorem(18)][Yep] 16 | #lorem(20) 17 | #set-margin-note-defaults(inline-numbering: auto, note-numbering: auto) 18 | 19 | #pagebreak() 20 | 21 | = `link = "line"` 22 | #set-margin-note-defaults(link: "line") 23 | 24 | #lorem(40) 25 | #margin-note[Hello world] 26 | #margin-note[Stacked] 27 | 28 | #lorem(40) 29 | #margin-note[#lorem(18)][Yep] 30 | #lorem(20) 31 | -------------------------------------------------------------------------------- /tests/misc.typ: -------------------------------------------------------------------------------- 1 | #import "../drafting.typ": * 2 | 3 | #set page(margin: (right: 2in), height: auto) 4 | = Default Test 5 | 6 | #lorem(10) 7 | #margin-note[ 8 | #lorem(10) #footnote[test footnote] #lorem(10) 9 | ]#lorem(30) 10 | 11 | #lorem(20)#inline-note(stroke: orange + 3pt, fill: green)[test inline note] 12 | #lorem(10) 13 | 14 | #note-outline() 15 | 16 | #set page(margin: (inside: 2in, outside: 1in), height: auto) 17 | = Inside/outside margins 18 | == Unspecified side = largest = right 19 | #set-page-properties() 20 | #let body = { 21 | lorem(20) 22 | margin-note[Largest side note] 23 | lorem(20) 24 | margin-note(side: left)[left note] 25 | margin-note(side: right)[right note] 26 | lorem(10) 27 | } 28 | #body 29 | #pagebreak() 30 | = Inside on left 31 | == Unspecified side = largest = left 32 | #body 33 | 34 | #set page(margin: 2in) 35 | #set-page-properties() 36 | = Margin is `length` 37 | #body 38 | -------------------------------------------------------------------------------- /typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "drafting" 3 | version = "0.2.2" 4 | entrypoint = "drafting.typ" 5 | authors = ["Nathan Jessurun", "Jens Tinggaard <@Tinggaard>"] 6 | license = "Unlicense" 7 | description = "Helpful functions for content positioning and margin comments/notes" 8 | repository = "https://github.com/ntjess/typst-drafting" 9 | keywords = [ 10 | "comments", 11 | "notes", 12 | "margins", 13 | "positioning", 14 | "layout", 15 | "ruler", 16 | "todo", 17 | ] 18 | categories = ["layout", "utility"] 19 | compiler = "0.13.0" 20 | 21 | [tool.packager] 22 | paths = ["drafting.typ", "LICENSE", "README.md"] 23 | -------------------------------------------------------------------------------- /update_readme.sh: -------------------------------------------------------------------------------- 1 | # See `showman md --help` for details 2 | showman md docs/overview/main.typ \ 3 | --output README.md \ 4 | --root_dir . \ 5 | --assets_dir assets \ 6 | --git_url https://www.github.com/ntjess/typst-drafting/v0.2.2/ \ 7 | # --log_level DEBUG \ 8 | --------------------------------------------------------------------------------