├── .gitignore ├── examples ├── .gitignore ├── logo.png ├── logo_big.png ├── DIN-5008-A.pdf ├── DIN-5008-B.pdf ├── C5-WINDOW-LEFT.pdf ├── C5-WINDOW-RIGHT.pdf ├── C5-WINDOW-LEFT.typ ├── C5-WINDOW-RIGHT.typ ├── DIN-5008-A.typ └── DIN-5008-B.typ ├── preview.png ├── typst.toml ├── LICENSE ├── README.md └── lttr.typ /.gitignore: -------------------------------------------------------------------------------- 1 | *.pdf 2 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | !*.pdf 2 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascal-huber/typst-letter-template/HEAD/preview.png -------------------------------------------------------------------------------- /examples/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascal-huber/typst-letter-template/HEAD/examples/logo.png -------------------------------------------------------------------------------- /examples/logo_big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascal-huber/typst-letter-template/HEAD/examples/logo_big.png -------------------------------------------------------------------------------- /examples/DIN-5008-A.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascal-huber/typst-letter-template/HEAD/examples/DIN-5008-A.pdf -------------------------------------------------------------------------------- /examples/DIN-5008-B.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascal-huber/typst-letter-template/HEAD/examples/DIN-5008-B.pdf -------------------------------------------------------------------------------- /examples/C5-WINDOW-LEFT.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascal-huber/typst-letter-template/HEAD/examples/C5-WINDOW-LEFT.pdf -------------------------------------------------------------------------------- /examples/C5-WINDOW-RIGHT.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pascal-huber/typst-letter-template/HEAD/examples/C5-WINDOW-RIGHT.pdf -------------------------------------------------------------------------------- /typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lttr" 3 | version = "1.0.0" 4 | repository = "https://github.com/pascal-huber/typst-letter-template" 5 | entrypoint = "lttr.typ" 6 | authors = [ 7 | "Pascal Huber ", 8 | ] 9 | license = "MIT" 10 | description = "Letter template" 11 | exclude = [ "/test", "/examples" ] 12 | -------------------------------------------------------------------------------- /examples/C5-WINDOW-LEFT.typ: -------------------------------------------------------------------------------- 1 | #import "@local/lttr:1.0.0": * 2 | #show: lttr-init.with( 3 | debug: true, 4 | format: "C5-WINDOW-LEFT", 5 | title: "Brief schribä mit Typst isch zimli eifach", 6 | opening: "Hoi Peter,", 7 | closing: "Uf widerluege", 8 | signature: "Ruedi", 9 | date-place: ( 10 | date: "20.04.2023", 11 | place: "Witfortistan", 12 | ), 13 | receiver: ( 14 | "Peter Empfänger", 15 | "Bahnhofsstrasse 16", 16 | "1234 Zimliwitwegistan", 17 | ), 18 | sender: ([ 19 | Ruedi Rösti\ 20 | Bahnhofsgasse 15\ 21 | 8957 Spreitenbach 22 | ]), 23 | ) 24 | 25 | #show: lttr-preamble 26 | 27 | #lorem(50) 28 | 29 | #show: lttr-closing 30 | -------------------------------------------------------------------------------- /examples/C5-WINDOW-RIGHT.typ: -------------------------------------------------------------------------------- 1 | #import "@local/lttr:1.0.0": * 2 | #show: lttr-init.with( 3 | debug: true, 4 | format: "C5-WINDOW-RIGHT", 5 | title: "Brief schribä mit Typst isch zimli eifach", 6 | opening: "Hoi Peter,", 7 | closing: "Uf widerluege", 8 | signature: "Ruedi", 9 | date-place: ( 10 | date: "20.04.2023", 11 | place: "Witfortistan", 12 | ), 13 | receiver: ( 14 | "Peter Empfänger", 15 | "Bahnhofsstrasse 16", 16 | "1234 Zimliwitwegistan", 17 | ), 18 | sender: ([ 19 | Ruedi Rösti\ 20 | Bahnhofsgasse 15\ 21 | 8957 Spreitenbach 22 | ]), 23 | ) 24 | 25 | #show: lttr-preamble 26 | 27 | #lorem(50) 28 | 29 | #show: lttr-closing 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Pascal Huber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/DIN-5008-A.typ: -------------------------------------------------------------------------------- 1 | #import "@local/lttr:1.0.0": * 2 | #show: lttr-init.with( 3 | debug: true, 4 | format: "DIN-5008-A", 5 | title: "Writing Letters in Typst is Easy", 6 | settings: ( 7 | min_content-spacing: 10cm, 8 | ), 9 | opening: "Dear Peter,", 10 | closing: "Peace, I'm out", 11 | signature: "Hans", 12 | date-place: ( 13 | date: "20.04.2023", 14 | place: "Weitfortistan", 15 | ), 16 | horizontal-table: ( 17 | ("Ihr Zeichen", "Bananalover149"), 18 | ("Ihre Nachricht vom", "12.12.2022"), 19 | ("Unser Zeichen", "BananaFactory"), 20 | ("Datum", "06.08.2023"), 21 | ), 22 | return-to: "Bananas Ltd · Fruitstreet 15 · 1234 Monkey City · Gorillaland", 23 | remark-zone: ( 24 | "Why would anyone write a remark?", 25 | "...hideous...", 26 | ), 27 | receiver: ( 28 | "Peter Bananeater", 29 | "Bahnhofsstrasse 16", 30 | "1234 Fruchtstadt", 31 | "Weitfortistan", 32 | ), 33 | sender: ([ 34 | #image("logo.png", width: 60%) 35 | Bananas Ltd.\ 36 | Fruitstreet 15\ 37 | 1234 Monkey City\ 38 | Gorillaland 39 | ]), 40 | ) 41 | 42 | #show: lttr-preamble 43 | 44 | #lorem(50) 45 | 46 | #lorem(20) 47 | 48 | #show: lttr-closing 49 | -------------------------------------------------------------------------------- /examples/DIN-5008-B.typ: -------------------------------------------------------------------------------- 1 | #import "@local/lttr:1.0.0": * 2 | #show : lttr-init.with( 3 | debug: true, 4 | format: "DIN-5008-B", 5 | title: "Banana Order Confirmation", 6 | opening: "Dear Peter,", 7 | closing: "Peace, I'm out", 8 | signature: "Hans", 9 | horizontal-table: ( 10 | // NOTE: we can override the default fmt to format the table entries 11 | fmt: (header, content) => [ 12 | #text(fill: green, size: 0.8em)[ 13 | #underline[#header] 14 | ] 15 | #linebreak() 16 | #content 17 | ], 18 | content: ( 19 | ("Ihr Zeichen", "Banana"), 20 | ("Ihre Nachricht vom", "12.12.2022"), 21 | ("Unser Zeichen", "BananaFactory"), 22 | ("Datum", "06.08.2023") 23 | ) 24 | ), 25 | date-place: ( 26 | date: "20.04.2023", 27 | place: "Monkey City", 28 | ), 29 | return-to: "Bananas Ltd · Fruitstreet 15 · 1234 Monkey City", 30 | receiver: ( 31 | "Peter Bananaeater", 32 | "Bahnhofsstrasse 16", 33 | "1234 Fruchtstadt", 34 | "Weitfortistan" 35 | ), 36 | sender: ( 37 | // NOTE: This overrides the DIN 5008 position.top because we want a big 38 | // banana image here 39 | position: (top: 3cm), 40 | content: [ 41 | #image("logo_big.png", width: 60%) 42 | Bananas Ltd.\ 43 | Fruitstreet 15\ 44 | 1234 Monkey City\ 45 | Gorillaland 46 | ] 47 | ), 48 | ) 49 | #show: lttr-preamble 50 | 51 | #lorem(100) 52 | 53 | #show: lttr-closing 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typst letter 2 | 3 | A customizable Typst letter template with some presets for DIN 5008 A/B and 4 | Swiss C5 Letter. Please note that the template is still under development and 5 | subject to breaking changes. 6 | 7 | ![preview](./preview.png) 8 | 9 | See the [examples](./examples) 10 | 11 | ## Templates 12 | 13 | - `lttr-init` is responsible to compute all values from the parameters and 14 | default values for different formats. It also sets the `page` and `text` 15 | attributes. 16 | 17 | - `lttr-preamble` renders: 18 | - `lttr-sender` 19 | - `lttr-receiver` 20 | - `lttr-indicator-lines` 21 | - `lttr-content-offset` 22 | - `lttr-horizontal-table` 23 | - `lttr-date-place` 24 | - `lttr-title` 25 | - `lttr-opening` 26 | 27 | - `lttr-closing` renders the closing line and the signature. 28 | 29 | ## Parameters 30 | 31 | All Parameters are optional and will override the global defaults and the 32 | defaults of the chosen format. Some of them allow to either specify the content 33 | directly or use a dict if other settings need to be changed also. For example: 34 | `receiver: "x"` is the same as `receiver: (content: "x")`. 35 | 36 | ### Basics 37 | 38 | - `debug` (`[Bool]`) 39 | Whether or not to show (colorful) debug lines. 40 | 41 | - `format` (`[String]`) 42 | Format of the letter (`"DIN-5008-A"`, `"DIN-5008-B"`, `"C5-WINDOW-RIGHT"`, 43 | `"C5-WINDOW-LEFT"`). 44 | 45 | - `_page` (`[Dict]`) 46 | Set page settings ([docs](https://typst.app/docs/reference/layout/page/)). 47 | 48 | - `_text` (`[Dict]`) 49 | Set text settings ([docs](https://typst.app/docs/reference/text/text/)). 50 | 51 | - `settings` (`[Dict]`) 52 | Basic settings. 53 | - `content-spacing` (`[Length]`) 54 | Minimum spacing between sender/receiver and letter content (or the 55 | horizontal table if present) and also the spacing after the horizontal 56 | table. 57 | - `justify-content` (`[Bool]`) 58 | Wheter or not to justify the content. 59 | 60 | Example: 61 | 62 | ```typst 63 | settings: ( 64 | content-spacing: 8.46mm, 65 | justify-content: true, 66 | ), 67 | ``` 68 | 69 | - `indicator-lines` (`[Dict]`) 70 | Info to render lines for the hole puncher and folding ([see below](#indicator-lines)). 71 | - `fold-marks` (`[Array]`) 72 | Lenghts (`[Length]`) from top of page of the fold marks 73 | - `show-puncher-mark` (`[Bool]`) 74 | Whether or not to show the puncher mark. 75 | 76 | Example: 77 | 78 | ```typst 79 | indicator-lines: ( 80 | fold-marks: (87mm, 87mm+105mm), 81 | show-puncher-mark: true, 82 | ) 83 | ``` 84 | 85 | ### Sender and Receiver 86 | 87 | - `receiver` (`[Array, Content, Dict]`) 88 | Info to render the receiver fields. 89 | - `content` (`[Array, Content]`) 90 | Content of the receiver field. 91 | - `dimensions` (`[Dict]`) 92 | Dimensions of the address field (`width: [Length]`, `height: [Length]`) 93 | - `fmt` (`[Function]`) 94 | Rendering function which takes the receiver (`[Dict]`) to format and show 95 | it. 96 | - `position` (`[Dict]`) 97 | Position of the address field (`top: [Length]`, `left: [Length]`) 98 | - `spacing` (`[Length]`) 99 | Spacing before the content. 100 | - `align` (`[Align]`) 101 | Alignment of the receiver field. 102 | 103 | Example: 104 | 105 | ```typst 106 | receiver: ( 107 | position: (top: 5cm) 108 | content: ( 109 | "Peter Doe", 110 | "Somestreet 16", 111 | "1234 New York", 112 | ), 113 | ), 114 | ``` 115 | 116 | - `return-to` (`[Array, Content, Dict, String]`) 117 | The returning address. 118 | - `content` (`[Array, Content]`) 119 | Content of the return-to field. 120 | - `dimensions` (`[Dict]`) 121 | Dimensions of the return-to field (`width: [Length]`, `height: [Length]`) 122 | - `fmt` (`[Function]`) 123 | Rendering function which takes the return-to (`[Dict]`) to format and show 124 | it. 125 | - `position` (`[Dict]`) 126 | Position of the return-to field (`top: [Length]`, `left: [Length]`) 127 | 128 | Example: 129 | 130 | ```typst 131 | return-to: "Some Address, I don't care...", 132 | ``` 133 | 134 | - `remark-zone` (`[Array, Content, Dict, String]`) 135 | The remark zone. 136 | - `align` (`[Align]`) 137 | Alignment of the remark-zone. 138 | - `content` (`[Array, Content, String]`) 139 | Content of the remark-zone field. 140 | - `dimensions` (`[Dict]`) 141 | Dimensions of the remark-zone field (`width: [Length]`, `height: [Length]`) 142 | - `fmt` (`[Function]`) 143 | Rendering function which takes the remark-zone (`[Dict]`) to format and show 144 | it. 145 | - `position` (`[Dict]`) 146 | Position of the remark-zone field (`top: [Length]`, `left: [Length]`) 147 | 148 | ```typst 149 | remark-zone: ( 150 | "This is a", 151 | "multiline remark", 152 | ) 153 | ``` 154 | 155 | - `sender` (`[Array, Content, Dict]`) 156 | Info to render the sender fields. 157 | - `content` (`[Array, Content]`) 158 | Content or array of lines for the sender field. 159 | - `fmt` [Function] 160 | Rendering function which takes the sender (`[Dict]`) to format and show it. 161 | - `position` (`[Dict]`) 162 | Position of the sender field. 163 | - `width` (`[Length]`) 164 | Width of the sender field. 165 | 166 | Example: 167 | 168 | ```typst 169 | sender: ( 170 | content: ( 171 | "John Doe", 172 | "Somestreet 15", 173 | "1234 New York", 174 | ) 175 | position: (left: 110mm, top: 20mm), 176 | width: 80mm, 177 | ), 178 | ``` 179 | 180 | - `horizontal-table` (`[Dict, Array]`) 181 | A table to add before the date, time and title. 182 | - `content` (`[Array]`) 183 | Array of of entries for the table where each entry is itself an array of 184 | exactly two items for title and body (`[Content, String]`) 185 | - `fmt` (`[Function]`) 186 | Formatting function which takes the title and body of a cell to format and 187 | show it. 188 | - `spacing` (`[Lenght]`) 189 | Spacing before the horizontal table. 190 | 191 | Example: 192 | 193 | ```typst 194 | horizontal-table: ( 195 | ("Ihr Zeichen", "Bananalover149"), 196 | ("Ihre Nachricht vom", "12.12.2022"), 197 | ("Unser Zeichen", "Bananenfabrik"), 198 | ("Datum", "12.08.2023"), 199 | ) 200 | ``` 201 | 202 | ### Letter Beginning 203 | 204 | - `opening` (`[Content, Dict, String]`) 205 | Info to render the `title` template ([see below](#opening)). 206 | - `content` (`[Content, String]`) 207 | Content of the opening (e.g. "Dear Sir...."). 208 | - `spacing` (`[Length]`) 209 | Spacing before the letter opening. 210 | 211 | Example: 212 | 213 | ```typst 214 | opening: ( 215 | content: "Dear Sir or Madam,", 216 | spacing: 2mm, 217 | ) 218 | ``` 219 | 220 | - `date-place` (`[Content, Dict, String]`) 221 | Info to render the `date-place` template ([see below](#date-place)). 222 | - `align` (`[Align]`) 223 | Alignment of the place and date 224 | - `date` (`[Content, String]`) 225 | Date of the letter. 226 | - `place` (`[Content, String]`) 227 | Place of the letter. 228 | 229 | Example: 230 | 231 | ```typst 232 | date-place: ( 233 | align: left, 234 | date: "20.04.2023", 235 | place: "Weitfortistan", 236 | ), 237 | ``` 238 | 239 | - `title` (`[Content, Dict, String]`) 240 | Info to render the `title` template. The title is also set as document 241 | property. 242 | - `content` (`[Content, String]`) 243 | Content of the title. 244 | - `spacing` (`[Length]`) 245 | Spacing before the title. 246 | 247 | Example: 248 | 249 | ```typst 250 | title: ( 251 | content: "Writing Letters in Typst is Easy", 252 | spacing: 2mm, 253 | ) 254 | ``` 255 | 256 | ### Letter Ending 257 | 258 | - `closing` (`[Content, Dict, String]`) 259 | Info to render the closing 260 | - `content` (`[Content, String]`) 261 | Content of the closing (e.g. "kind regards"). 262 | - `spacing` (`[Length]`) 263 | Spacing before the closing. 264 | 265 | Example: 266 | 267 | ```typst 268 | closing: "kind regards" 269 | ``` 270 | 271 | - `signature` (`[Dict, none]`) 272 | Info to render the signature. 273 | - `content` (`[Content]`) 274 | Content of the signature 275 | - `spacing` (`[Length]`) 276 | Spacing before the signature. 277 | 278 | Example: 279 | 280 | ```typst 281 | signature: ( 282 | content: "Peter Pan (with the big Signature)", 283 | spacing: 16mm, 284 | ) 285 | ``` 286 | 287 | ## Other functions 288 | 289 | - `lttr-state` prints the entire state used to render the components. This can 290 | be useful for debugging purposes. 291 | 292 | ## Resources 293 | 294 | - [DIN 5008 Form A](https://de.wikipedia.org/wiki/DIN_5008#/media/Datei:DIN_5008,_Form_A.svg) 295 | - [DIN 5008 Form B](https://de.wikipedia.org/wiki/DIN_5008#/media/Datei:DIN_5008_Form_B.svg) 296 | - [Swiss 297 | Addressing](https://www.post.ch/-/media/portal-opp/pm/dokumente/briefe-spezifikation-gestaltung.pdf?sc_lang=de&hash=BB181E74C5D3A0D1D49A954793EA670A) 298 | 299 | ## Similar Projects 300 | 301 | - [dvdvgt/typst-letter](https://github.com/dvdvgt/typst-letter): A typst 302 | template for a DIN 5008 inspired letter with the goal to fit nicely into C6/5 303 | envelops. 304 | - [qjcq/awesome-typst](https://github.com/qjcg/awesome-typst): Awesome Typst 305 | Links 306 | 307 | ## Development Setup 308 | 309 | Currently, I just create a symlink such that I can import it with `#import 310 | "@local/lttr:1.0.0": *` as follows. 311 | 312 | ```bash 313 | mkdir -p ${XDG_DATA_HOME}/typst/packages/local/lttr/ 314 | ln -s /path/to/this/repo ${XDG_DATA_HOME}/typst/packages/local/lttr/1.0.0 315 | ``` 316 | 317 | ## Installation 318 | 319 | While there exists a first version of typst packages, they do not yet accept 320 | custom templates (afaik). For the meantime, you can download and extract the 321 | release tarball to `${XDG_DATA_HOME}/typst/packages/local/lttr/` and 322 | import it as described in [Development Setup](#development-setup). 323 | 324 | ## Roadmap 325 | 326 | There are a couple of limitations in typst which I hope will be addressed. 327 | 328 | - [ ] There is currently no way to query properties set with `set`. This would 329 | be nice to query the document title and author names 330 | [issue](https://github.com/typst/typst/issues/763). Forthermore, it is not 331 | possible to call `set` after the first lttr function has been called (even if 332 | no content was rendered added). 333 | - [ ] datetime with locales settings 334 | 335 | Other things: 336 | 337 | - [ ] Add more layouts including (us letter, ?) 338 | - [ ] Vertical table for sender field as for example 339 | [here](https://www.onlineprinters.de/magazin/wp-content/uploads/2021/07/Vorlage_Geschaeftsbrief_DIN-5008_Form-A.jpg) 340 | - [ ] Maybe add lines with labels to display measurements/sizes in debug mode 341 | - [ ] Add this to the typst preview packages. Currently, they apparently do not 342 | accept packages. 343 | -------------------------------------------------------------------------------- /lttr.typ: -------------------------------------------------------------------------------- 1 | // NOTE: lttr-data contains all the data to render the letter 2 | #let lttr-data = state("letter", none) 3 | 4 | // NOTE: lttr-max-dy keeps track of the largest offset dy (from the top margin) 5 | // of absolutely positioned content (sender and receiver fields) such that we 6 | // know how much vertical offset we need to add at the beginning of the letter 7 | // content. Note that length does not include the value of 8 | // `settings.content-spacing`. 9 | #let lttr-max-dy = state("lttr-max-dy", 0cm) 10 | #let lttr-update-max-dy(dy) = context { 11 | lttr-max-dy.update(x => calc.max(x, dy)) 12 | } 13 | 14 | #let lttr-fmt(it) = { 15 | if type(it) == array { 16 | let ctr = 0 17 | for line in it { 18 | lttr-fmt(line) 19 | if ctr != it.len() { 20 | linebreak() 21 | } 22 | ctr += 1 23 | } 24 | } else { 25 | [#it] 26 | } 27 | } 28 | 29 | #let lttr-defaults = ( 30 | _page: ( 31 | margin: ( 32 | top: 3cm, 33 | bottom: 3cm, 34 | left: 25mm, 35 | right: 20mm, 36 | ), 37 | ), 38 | _text: ( 39 | size: 11pt, 40 | ), 41 | settings: ( 42 | content-spacing: 10mm, 43 | justify-content: true, 44 | ), 45 | sender: ( 46 | content: none, 47 | fmt: (it) => { 48 | lttr-fmt(it.content) 49 | }, 50 | ), 51 | return-to: ( 52 | content: none, 53 | fmt: (it) => { 54 | text(size: 0.8em)[#underline({ 55 | lttr-fmt(it.content) 56 | })] 57 | }, 58 | ), 59 | remark-zone: ( 60 | content: none, 61 | fmt: (it) => { 62 | set align(it.align) 63 | text(size: 0.8em)[ 64 | #lttr-fmt(it.content) 65 | ] 66 | }, 67 | ), 68 | receiver: ( 69 | content: none, 70 | fmt: (it) => { 71 | lttr-fmt(it.content) 72 | }, 73 | spacing: 0.65em / 2, // NOTE: half the default spacing between lines 74 | ), 75 | date-place: ( 76 | date: none, 77 | place: none, 78 | ), 79 | horizontal-table: ( 80 | content: none, 81 | fmt: (header, content) => { 82 | set par(leading: 0.4em) 83 | text(size: 0.8em)[ 84 | #lttr-fmt(header) 85 | #linebreak() 86 | ] 87 | lttr-fmt(content) 88 | }, 89 | spacing: 10mm, 90 | ), 91 | title: ( 92 | content: none, 93 | spacing: 2mm, 94 | ), 95 | opening: ( 96 | content: none, 97 | spacing: 2mm, 98 | ), 99 | closing: ( 100 | content: none, 101 | spacing: 5mm, 102 | ), 103 | signature: ( 104 | content: none, 105 | spacing: 5mm, 106 | ) 107 | ) 108 | 109 | #let lttr-format-defaults = ( 110 | "DIN-5008-A": ( 111 | _page: ( 112 | paper: "a4", 113 | ), 114 | _text: ( 115 | lang: "DE" 116 | ), 117 | settings: ( 118 | content-spacing: 8.46mm, 119 | ), 120 | horizontal-table: ( 121 | spacing: 8.46mm, 122 | ), 123 | sender: ( 124 | position: (left: 125mm, top: 32mm), 125 | width: 75mm, 126 | ), 127 | return-to: ( 128 | position: (left: 20mm + 5mm, top: 27mm), 129 | dimensions: (height: 5mm, width: 85mm - 5mm), 130 | ), 131 | remark-zone: ( 132 | position: (left: 20mm + 5mm, top: 27mm + 5mm), 133 | dimensions: (height: 12.7mm, width: 85mm - 5mm), 134 | align: top, 135 | ), 136 | receiver: ( 137 | position: (left: 20mm + 5mm, top: 27mm + 17.7mm), 138 | dimensions: (height: 27.3mm, width: 85mm - 5mm), 139 | align: top, 140 | ), 141 | indicator-lines: ( 142 | show-puncher-mark: true, 143 | fold-marks: (87mm, 87mm+105mm), 144 | ), 145 | date-place: ( 146 | align: right, 147 | ) 148 | ), 149 | "DIN-5008-B": ( 150 | _page: ( 151 | paper: "a4", 152 | ), 153 | _text: ( 154 | lang: "DE" 155 | ), 156 | settings: ( 157 | content-spacing: 8.46mm, 158 | ), 159 | horizontal-table: ( 160 | spacing: 8.46mm, 161 | ), 162 | sender: ( 163 | position: (left: 125mm, top: 50mm), 164 | width: 75mm, 165 | ), 166 | return-to: ( 167 | // NOTE: this position overlapps with remark-zone. DIN-5008-B does 168 | // not have a dedicated return-to field. If both return-to and 169 | // remark-zone have a non-none content, then the remark-zone field 170 | // has to be recuced by return-to.dimensions.height 171 | position: (left: 20mm + 5mm, top: 45mm), 172 | dimensions: (height: 5mm, width: 85mm - 5mm), 173 | ), 174 | remark-zone: ( 175 | position: (left: 20mm + 5mm, top: 45mm), 176 | dimensions: (height: 12.7mm + 5mm, width: 85mm - 5mm), 177 | align: bottom, 178 | ), 179 | receiver: ( 180 | position: (left: 20mm + 5mm, top: 45mm + 17.7mm), 181 | dimensions: (height: 27.3mm, width: 85mm - 5mm), 182 | align: top, 183 | ), 184 | indicator-lines: ( 185 | show-puncher-mark: true, 186 | fold-marks: (105mm, 105mm + 105mm), 187 | ), 188 | date-place: ( 189 | align: right, 190 | ) 191 | ), 192 | "C5-WINDOW-LEFT": ( 193 | _page: ( 194 | paper: "a4", 195 | ), 196 | _text: ( 197 | lang: "CH", 198 | ), 199 | settings: ( 200 | content-spacing: 10mm, 201 | ), 202 | horizontal-table: ( 203 | spacing: 10mm, 204 | ), 205 | sender: ( 206 | // NOTE: position.top is the margin.top 207 | // NOTE: position.left and width are like DIN5008 208 | position: (left: 125mm, top: 30mm), 209 | width: 75mm, 210 | ), 211 | return-to: ( 212 | position: none, 213 | ), 214 | remark-zone: ( 215 | position: none, 216 | ), 217 | receiver: ( 218 | // NOTE: I added a 5mm "padding" on the left here 219 | position: (left: 20mm + 5mm, top: 52mm), 220 | // NOTE: height = Window_height - (C5_height - Paper_height) 221 | // = 45mm - (162mm - 297mm/2) 222 | // = 31.5mm 223 | // NOTE: width = Window_width - (C5_width - Paper_width) 224 | // = 100mm - (229mm - 210mm) 225 | // = 81mm 226 | dimensions: (height: 31.5mm, width: 81mm), 227 | align: horizon, 228 | ), 229 | indicator-lines: ( 230 | fold-marks: (), 231 | show-puncher-mark: true, 232 | ), 233 | date-place: ( 234 | align: left, 235 | ) 236 | ), 237 | "C5-WINDOW-RIGHT": ( 238 | _page: ( 239 | paper: "a4", 240 | ), 241 | _text: ( 242 | lang: "CH", 243 | ), 244 | settings: ( 245 | content-spacing: 10mm, 246 | ), 247 | horizontal-table: ( 248 | spacing: 10mm, 249 | ), 250 | sender: ( 251 | position: none, 252 | width: 75mm, 253 | ), 254 | return-to: ( 255 | position: none, 256 | ), 257 | remark-zone: ( 258 | position: none, 259 | ), 260 | receiver: ( 261 | position: (left: 120mm, top: 52mm), 262 | dimensions: (height: 31.5mm, width: 80mm), 263 | align: horizon, 264 | ), 265 | indicator-lines: ( 266 | fold-marks: (), 267 | show-puncher-mark: true, 268 | ), 269 | date-place: ( 270 | align: left, 271 | ) 272 | ), 273 | ) 274 | 275 | #let lttr-indicator-lines(body) = context { 276 | let state = lttr-data.at(here()); 277 | if state.indicator-lines != none { 278 | if state.indicator-lines.show-puncher-mark { 279 | place( 280 | dy: 50% - 0.5 * state._page.margin.top + 0.5 * state._page.margin.bottom, 281 | dx: 0cm - state._page.margin.left + 9mm, 282 | line( 283 | length: 0.4cm, 284 | stroke: 0.5pt + rgb("#777777") 285 | ) 286 | ) 287 | } 288 | if type(state.indicator-lines.fold-marks) == array { 289 | for mark in state.indicator-lines.fold-marks { 290 | place( 291 | dy: mark - state._page.margin.top, 292 | dx: 0cm - state._page.margin.left + 9mm, 293 | line( 294 | length: 0.2cm, 295 | stroke: 0.5pt + rgb("#777777") 296 | ) 297 | ) 298 | } 299 | } 300 | } 301 | body 302 | } 303 | 304 | #let lttr-horizontal-table( 305 | body 306 | ) = context { 307 | let state = lttr-data.at(here()); 308 | if state.horizontal-table.content != none { 309 | let content = () 310 | let ctr = 0 311 | for entry in state.horizontal-table.content { 312 | ctr += 1 313 | content.push({ 314 | state.horizontal-table.at("fmt")( 315 | entry.first(), 316 | entry.last(), 317 | ) 318 | }) 319 | } 320 | if ctr > 0 { 321 | let column-width = 100% / ctr 322 | let columns = () 323 | while ctr > 0 { 324 | columns.push(column-width) 325 | ctr -= 1 326 | } 327 | let tbl = table( 328 | columns: columns, 329 | inset: 0pt, 330 | stroke: if state.debug {red} else {none}, 331 | align: (left, top), 332 | ..content 333 | ) 334 | let table-rect = rect( 335 | outset: 0pt, 336 | inset: 0pt, 337 | stroke: none, 338 | tbl 339 | ) 340 | let dy = lttr-max-dy.at(here()) + state.horizontal-table.spacing 341 | place( 342 | dy: dy, 343 | { 344 | table-rect 345 | layout(size => { 346 | let (height,) = measure( 347 | block(width: size.width, table-rect) 348 | ) 349 | lttr-update-max-dy(height + dy) 350 | }) 351 | } 352 | ) 353 | } 354 | } 355 | body 356 | } 357 | 358 | #let lttr-closing(body) = context { 359 | let state = lttr-data.at(here()); 360 | if state.closing.content != none { 361 | v(state.closing.spacing) 362 | state.closing.content 363 | } 364 | if state.signature.content != none { 365 | v(state.signature.spacing) 366 | state.signature.content 367 | } 368 | body 369 | } 370 | 371 | #let lttr-opening(body) = context { 372 | let state = lttr-data.at(here()); 373 | if state.opening != none { 374 | v(state.opening.spacing) 375 | state.opening.content 376 | } 377 | body 378 | } 379 | 380 | #let lttr-date-place(body) = context { 381 | let state = lttr-data.at(here()); 382 | if state.date-place != none { 383 | set align(state.date-place.align) 384 | state.date-place.place 385 | if state.date-place.place != none and state.date-place.date != none { 386 | text(", ") 387 | } 388 | state.date-place.date 389 | } 390 | body 391 | } 392 | 393 | #let lttr-title(body) = context { 394 | let state = lttr-data.at(here()); 395 | if state.title != none { 396 | v(state.title.spacing) 397 | text( 398 | weight: "bold", 399 | size: 1.0em, 400 | state.title.content 401 | ) 402 | } 403 | body 404 | } 405 | 406 | #let lttr-sender(body) = context { 407 | let state = lttr-data.at(here()); 408 | let sender-rect = rect( 409 | width: state.sender.width, 410 | inset: 0pt, 411 | outset: 0pt, 412 | stroke: if state.debug {blue} else {none}, 413 | { 414 | state.sender.at("fmt")(state.sender) 415 | } 416 | ) 417 | let sender-position = if state.sender.position != none { 418 | state.sender.position 419 | } else { 420 | (left: state._page.margin.left, top: state._page.margin.top) 421 | } 422 | let dy = sender-position.top - state._page.margin.top 423 | let dx = sender-position.left - state._page.margin.left 424 | place( 425 | dy: dy, 426 | dx: dx, 427 | sender-rect 428 | ) 429 | // TODO: add layout here 430 | lttr-update-max-dy(measure(sender-rect).height + dy) 431 | body 432 | } 433 | 434 | #let lttr-receiver-return-to(body) = context { 435 | let state = lttr-data.at(here()); 436 | if state.return-to.position != none { 437 | let dy = state.return-to.position.top - state._page.margin.top 438 | let dx = state.return-to.position.left - state._page.margin.left 439 | place( 440 | dy: dy, 441 | dx: dx, 442 | rect( 443 | width: state.return-to.dimensions.width, 444 | height: 5mm, 445 | stroke: if state.debug {red} else {none}, 446 | inset: (left: 0mm, right: 0mm), 447 | outset: 0cm, 448 | { 449 | state.return-to.at("fmt")(state.return-to) 450 | } 451 | ) 452 | ) 453 | } 454 | body 455 | } 456 | 457 | #let lttr-receiver-remark-zone(body) = context { 458 | let state = lttr-data.at(here()); 459 | if state.remark-zone.position != none { 460 | let dy = state.remark-zone.position.top - state._page.margin.top 461 | let dx = state.remark-zone.position.left - state._page.margin.left 462 | place( 463 | dy: dy, 464 | dx: dx, 465 | rect( 466 | width: state.remark-zone.dimensions.width, 467 | height: state.remark-zone.dimensions.height, 468 | stroke: if state.debug {green} else {none}, 469 | inset: (left: 0mm, right: 0mm), 470 | outset: 0pt, 471 | { 472 | state.remark-zone.at("fmt")(state.remark-zone) 473 | } 474 | ) 475 | ) 476 | } 477 | body 478 | } 479 | 480 | #let lttr-receiver-address(body) = { 481 | context { 482 | let state = lttr-data.at(here()); 483 | let receiver-rect = rect( 484 | width: state.receiver.dimensions.width, 485 | height: state.receiver.dimensions.height, 486 | stroke: if state.debug {purple} else {none}, 487 | inset: (left: 0mm, right: 0mm, top: 0mm), 488 | outset: 0pt, 489 | { 490 | v(state.receiver.spacing) 491 | set align(state.receiver.align) 492 | state.receiver.at("fmt")(state.receiver) 493 | }, 494 | ) 495 | let dy = state.receiver.position.top - state._page.margin.top 496 | let dx = state.receiver.position.left - state._page.margin.left 497 | place( 498 | dy: dy, 499 | dx: dx, 500 | receiver-rect, 501 | ) 502 | // TODO: add layout here 503 | let rect-height = measure(receiver-rect).height 504 | lttr-update-max-dy(rect-height + dy) 505 | } 506 | body 507 | } 508 | 509 | #let lttr-receiver(body) = { 510 | show: lttr-receiver-return-to 511 | show: lttr-receiver-remark-zone 512 | show: lttr-receiver-address 513 | body 514 | } 515 | 516 | #let lttr-content-offset(body) = context { 517 | let state = lttr-data.at(here()); 518 | v(lttr-max-dy.at(here()) + state.settings.content-spacing) 519 | set par(justify: state.settings.justify-content) 520 | body 521 | } 522 | 523 | #let lttr-preamble(body) = { 524 | show: lttr-sender 525 | show: lttr-receiver 526 | show: lttr-horizontal-table 527 | show: lttr-indicator-lines 528 | show: lttr-content-offset 529 | show: lttr-date-place 530 | show: lttr-title 531 | show: lttr-opening 532 | body 533 | } 534 | 535 | #let lttr-init( 536 | _page: (:), 537 | _text: (:), 538 | debug: false, 539 | format: "DIN-5008-A", 540 | settings: (:), 541 | indicator-lines: (:), 542 | sender: (:), 543 | return-to: (:), 544 | remark-zone: (:), 545 | receiver: (:), 546 | horizontal-table: (:), 547 | title: (:), 548 | date-place: (:), 549 | opening: (:), 550 | closing: (:), 551 | signature: (:), 552 | body, 553 | ) = { 554 | let format-defaults = lttr-format-defaults.at(format) 555 | 556 | // Takes an array of dictionaries and merges them where later dictionaries 557 | // overwrite the values of the ones before 558 | let lttr-deep-dict-merge(dictionaries) = { 559 | if dictionaries.len() == 0 { 560 | return (:) 561 | } else if dictionaries.len() == 1 { 562 | return dictionaries.first() 563 | } 564 | 565 | // helper function to deeply merge d1 and d2 (d2 overwrites d1) 566 | let deep-merge(d1, d2) = { 567 | let keys = (..d1.keys(), ..d2.keys()) 568 | for k in keys { 569 | if d1.keys().contains(k) and d2.keys().contains(k) { 570 | let d1-val = d1.at(k) 571 | let d2-val = d2.at(k) 572 | if type(d1-val) == dictionary and type(d2-val) == dictionary { 573 | // both d1 and d2 contain key k both d1.at(k) and 574 | // d2.at(k) are dictionaries, merge them 575 | d2.insert(k, deep-merge(d1-val, d2-val)) 576 | } 577 | } else if d1.keys().contains(k) { 578 | // key only exists in d1, add it to d2 579 | d2.insert(k, d1.at(k)) 580 | } 581 | } 582 | return d2 583 | } 584 | 585 | let result = none 586 | for dict in dictionaries { 587 | if result == none { 588 | result = dict 589 | } else { 590 | result = deep-merge(result, dict) 591 | } 592 | } 593 | return result 594 | } 595 | 596 | let merge-arg-dicts = (item-name, item) => { 597 | if item == none { 598 | none 599 | } else { 600 | lttr-deep-dict-merge(( 601 | lttr-defaults.at(item-name, default: (:)), 602 | format-defaults.at(item-name, default: (:)), 603 | if type(item) != dictionary { 604 | (content: item) 605 | } else { 606 | item 607 | } 608 | )) 609 | } 610 | } 611 | 612 | let data = ( 613 | _page: merge-arg-dicts("_page", _page), 614 | _text: merge-arg-dicts("_text", _text), 615 | closing: merge-arg-dicts("closing", closing), 616 | date-place: merge-arg-dicts("date-place", date-place), 617 | debug: debug, 618 | format: format, 619 | horizontal-table: merge-arg-dicts("horizontal-table", horizontal-table), 620 | indicator-lines: merge-arg-dicts("indicator-lines", indicator-lines), 621 | opening: merge-arg-dicts("opening", opening), 622 | receiver: merge-arg-dicts("receiver", receiver), 623 | remark-zone: merge-arg-dicts("remark-zone", remark-zone), 624 | return-to: merge-arg-dicts("return-to", return-to), 625 | sender: merge-arg-dicts("sender", sender), 626 | settings: merge-arg-dicts("settings", sender), 627 | signature: merge-arg-dicts("signature", signature), 628 | title: merge-arg-dicts("title", title), 629 | ) 630 | 631 | // NOTE: This is a special case for DIN-5008-B as described in the format 632 | // defaults 633 | if data.format == "DIN-5008-B" { 634 | if data.return-to.content != none and data.remark-zone.content != none { 635 | // we have both return-to and remark-zone, reduce the size of 636 | // remark-zone and shift it down 637 | data.remark-zone.dimensions.height = data.remark-zone.dimensions.height - data.return-to.dimensions.height 638 | data.remark-zone.position.top = data.remark-zone.position.top + data.return-to.dimensions.height 639 | } else if data.return-to.content != none { 640 | // there is no remark-zone, shift the return-to down 641 | data.return-to.position.top = data.receiver.position.top - data.return-to.dimensions.height 642 | } 643 | } 644 | 645 | // FIXME: find a better way to (not) set document attributes 646 | set page(..data._page) 647 | set text(..data._text) 648 | lttr-data.update(x => data) 649 | body 650 | } 651 | 652 | #let lttr-state() = context { 653 | lttr-data.at(here()); 654 | } 655 | --------------------------------------------------------------------------------