├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
120 |
121 | # Outline of all notes
122 |
123 | ``` typst
124 | #note-outline()
125 | ```
126 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------