@airmark/quiver 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +3 -2
  2. package/package.json +3 -3
  3. package/quills/daf4392/0.1.0/Quill.yaml +2 -2
  4. package/quills/usaf_memo/0.2.0/Quill.yaml +14 -11
  5. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/body.typ +37 -15
  6. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +5 -6
  7. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +5 -3
  8. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +87 -35
  9. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/utils.typ +34 -1
  10. package/quills/usaf_memo/0.3.0/Quill.yaml +316 -0
  11. package/quills/usaf_memo/0.3.0/assets/dod_seal.png +0 -0
  12. package/quills/usaf_memo/0.3.0/assets/dow_seal.png +0 -0
  13. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/.gitignore +7 -0
  14. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/LICENSE +21 -0
  15. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel[wght].ttf +0 -0
  16. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE +93 -0
  17. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Bold.otf +0 -0
  18. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
  19. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/LICENSE.md +79 -0
  20. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/LiberationMono/LICENSE +102 -0
  21. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/LiberationMono/LiberationMono-Regular.ttf +0 -0
  22. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
  23. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
  24. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
  25. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
  26. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
  27. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +29 -0
  28. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +364 -0
  29. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/config.typ +78 -0
  30. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +242 -0
  31. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +170 -0
  32. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +57 -0
  33. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +27 -0
  34. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +462 -0
  35. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +419 -0
  36. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/typst.toml +27 -0
  37. package/quills/usaf_memo/0.3.0/plate.typ +127 -0
@@ -0,0 +1,419 @@
1
+ // utils.typ: Utility functions and backend code for Typst usaf-memo package.
2
+ //
3
+ // This module provides core utility functions, configuration constants, and helper
4
+ // functions used by the main memorandum template. It handles spacing calculations,
5
+ // paragraph numbering, grid layouts, and various formatting utilities required
6
+ // for AFH 33-337 compliance.
7
+ //
8
+ // Key components:
9
+ // - Spacing constants and configuration management
10
+ // - Paragraph numbering and indentation utilities
11
+ // - Grid layout and backmatter formatting functions
12
+ // - Date formatting and content scaling utilities
13
+ // - Indorsement processing and ordinal number generation
14
+
15
+ #import "config.typ": CLASSIFICATION_COLORS, counters, paragraph-config, spacing
16
+
17
+ // Shared measured line-stride cache used by body line-count heuristics.
18
+ // Value is a `length` set once in `frontmatter`.
19
+ #let LINE_STRIDE = state("LINE_STRIDE")
20
+
21
+ /// Creates vertical spacing equivalent to multiple blank lines.
22
+ ///
23
+ /// Adds `count` wrapped-line strides on top of the natural inter-paragraph
24
+ /// gap, so a blank line occupies exactly the same vertical space as a line
25
+ /// produced by natural paragraph wrapping. The stride is measured from
26
+ /// `LINE_STRIDE` (cached in `frontmatter`) and falls back to an inline
27
+ /// measurement when the cache is unset.
28
+ ///
29
+ /// - count (int): Number of blank lines to create
30
+ /// -> content
31
+ #let blank-lines(count) = {
32
+ if count <= 0 { return }
33
+ context {
34
+ let stride = LINE_STRIDE.get()
35
+ if stride == none {
36
+ let one-line = measure(par(spacing: 0pt)[x]).height
37
+ stride = measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
38
+ }
39
+ v(stride * count)
40
+ }
41
+ }
42
+
43
+ /// Creates vertical spacing equivalent to one blank line.
44
+ /// Convenience function for single line spacing.
45
+ ///
46
+ /// -> content
47
+ #let blank-line() = blank-lines(1)
48
+
49
+ // =============================================================================
50
+ // GENERAL UTILITY FUNCTIONS
51
+ // =============================================================================
52
+
53
+ /// Checks if a value is "falsey" (none, false, empty array, empty string, or
54
+ /// empty content).
55
+ ///
56
+ /// Provides a consistent way to test for empty or missing values across
57
+ /// the template system. Used for conditional rendering of optional sections.
58
+ ///
59
+ /// Content fields (`plaintext` / `richtext`) reach the template in one of two
60
+ /// shapes: a blank one as the empty string `""`, a filled one as a markup
61
+ /// block. Empty content (`[]`) is covered too, since that is what an emptied
62
+ /// block and `join()`-over-nothing produce.
63
+ ///
64
+ /// - value (any): The value to check for "falsey" status
65
+ /// -> bool
66
+ #let falsey(value) = {
67
+ (
68
+ value == none
69
+ or value == false
70
+ or (type(value) == array and value.len() == 0)
71
+ or (type(value) == str and value == "")
72
+ or value == []
73
+ )
74
+ }
75
+
76
+ /// Scales content to fit within a specified box while maintaining aspect ratio.
77
+ ///
78
+ /// Automatically measures content and calculates uniform scaling to fit within
79
+ /// the given dimensions. Commonly used for letterhead seals and other images
80
+ /// that need to fit specific size constraints while preserving proportions.
81
+ ///
82
+ /// - width (length): Maximum width for the content (default: 2in)
83
+ /// - height (length): Maximum height for the content (default: 1in)
84
+ /// - alignment (alignment): Content alignment within the box (default: left+horizon)
85
+ /// - body (content): Content to scale and fit
86
+ /// -> content
87
+ #let fit-box(width: 2in, height: 1in, alignment: left + horizon, body) = context {
88
+ // 1) measure the unscaled content
89
+ let s = measure(body)
90
+
91
+ // 2) compute the uniform scale that fits inside the box
92
+ let f = calc.min(width / s.width, height / s.height) * 100% // ratio
93
+
94
+ // 3) fixed-size box, center the scaled content, and reflow so layout respects it
95
+ box(width: width, height: height, clip: true)[
96
+ #align(alignment)[
97
+ #scale(f, reflow: true)[#body]
98
+ ]
99
+ ]
100
+ }
101
+
102
+ /// Shrinks content to fit within a maximum width, never enlarging it.
103
+ ///
104
+ /// Measures `body` at its current font size and scales it down uniformly only
105
+ /// when it is wider than `width`; content that already fits keeps its set size
106
+ /// (the scale is capped at 100%). Unlike `fit-box`, height flows naturally and
107
+ /// small content is never scaled up. Used for the letterhead caption (unit
108
+ /// designation): a long name is shrunk to the width that clears the seal rather
109
+ /// than running underneath it. Pass a non-wrapping `box` as `body` so the
110
+ /// measured width is the single-line width and the result stays on one line.
111
+ ///
112
+ /// Empty content measures 0pt wide and has no scale to compute, so it is returned
113
+ /// as-is rather than divided by; a non-positive `width` likewise admits no scale
114
+ /// that fits, so the body is left at its set size instead of being flipped or
115
+ /// collapsed.
116
+ ///
117
+ /// - width (length): Maximum width the content may occupy
118
+ /// - alignment (alignment): Horizontal placement within the reserved width
119
+ /// - body (content): Content to fit (typically a non-wrapping `box`)
120
+ /// -> content
121
+ #let fit-to-width(width, alignment: left, body) = context {
122
+ let s = measure(body)
123
+ if s.width <= 0pt or width <= 0pt { return body }
124
+ let f = calc.min(1, width / s.width) * 100% // ratio, capped so it only shrinks
125
+ box(width: width)[
126
+ #align(alignment)[
127
+ #scale(f, reflow: true)[#body]
128
+ ]
129
+ ]
130
+ }
131
+
132
+ /// Formats a date for the memo heading.
133
+ ///
134
+ /// Dispatches on the shape a date field can arrive in:
135
+ /// - str: shown as-is (fixed text like a placeholder).
136
+ /// - datetime: a native Typst datetime (e.g. `datetime.today()`) — USAF style
137
+ /// `DD Month YYYY`, DAF style `Month DD, YYYY`.
138
+ /// - dict: a Quillmark `date`/`datetime` field lowers to a click-to-edit value
139
+ /// object `{ value: datetime, display: closure }`; `display` is a stored closure
140
+ /// over `.display`, so call it through parentheses — a dict has no `.display` method.
141
+ ///
142
+ /// - date (str | datetime | dict): Date to format for display
143
+ /// - memo-style (str): `"usaf"` or `"daf"`
144
+ /// -> content
145
+ #let display-date(date, memo-style: "usaf") = {
146
+ assert(
147
+ memo-style in ("usaf", "daf"),
148
+ message: "memo-style for display-date must be \"usaf\" or \"daf\"",
149
+ )
150
+ if type(date) == str {
151
+ date
152
+ } else {
153
+ let pattern = if memo-style == "daf" {
154
+ "[month repr:long] [day padding:none], [year]"
155
+ } else {
156
+ "[day padding:none] [month repr:long] [year]"
157
+ }
158
+ if type(date) == datetime {
159
+ date.display(pattern)
160
+ } else {
161
+ (date.display)(pattern)
162
+ }
163
+ }
164
+ }
165
+
166
+ /// Renders a horizontal rule sized to fit a handwritten date.
167
+ ///
168
+ /// Used for indorsements whose signing date is unknown at compile time: the
169
+ /// endorser writes the date on the line by hand when signing. The rule sits at
170
+ /// the baseline with one line of height above it so handwritten text can be
171
+ /// written on the line without colliding with surrounding header text.
172
+ ///
173
+ /// - width (length): Length of the fill-in rule; defaults to fit a long date
174
+ /// such as "15 September 2026".
175
+ /// -> content
176
+ #let date-placeholder-line(width: 1in) = box(
177
+ width: width,
178
+ height: 1em,
179
+ // Keep the rule on the line's baseline so it aligns with where the printed
180
+ // date would sit. The 1em box height reserves the writing space above it.
181
+ // (A positive baseline shift would drop the rule a full line too low.)
182
+ baseline: 0pt,
183
+ stroke: (bottom: 0.5pt + black),
184
+ )
185
+
186
+ /// Gets the banner color for a classification marking.
187
+ ///
188
+ /// Matches when `level` (trimmed) starts with a known prefix: TOP SECRET, SECRET,
189
+ /// CONFIDENTIAL, CUI, or UNCLASSIFIED. Otherwise returns black.
190
+ ///
191
+ /// - level (str): Marking string shown in header/footer
192
+ /// -> color
193
+ #let get-classification-level-color(level) = {
194
+ if level == none or type(level) != str {
195
+ return rgb(0, 0, 0)
196
+ }
197
+ let s = level.trim()
198
+ // Longest-prefix-first so e.g. "TOP SECRET" wins over "SECRET".
199
+ let level-order = ("TOP SECRET", "SECRET", "CONFIDENTIAL", "CUI", "UNCLASSIFIED")
200
+ for base-level in level-order {
201
+ if s.starts-with(base-level) {
202
+ return CLASSIFICATION_COLORS.at(base-level)
203
+ }
204
+ }
205
+ rgb(0, 0, 0)
206
+ }
207
+
208
+ // =============================================================================
209
+ // GRID LAYOUT UTILITIES
210
+ // =============================================================================
211
+
212
+ /// Creates an automatic grid layout from a cell value or array of them.
213
+ ///
214
+ /// Converts 1D content into a multi-column grid layout with proper spacing.
215
+ /// Used primarily for formatting recipient lists in the "MEMORANDUM FOR" section
216
+ /// where multiple organizations need to be displayed in columns.
217
+ ///
218
+ /// Features:
219
+ /// - Automatic column distribution and row filling
220
+ /// - Configurable column spacing and count
221
+ /// - Handles a single cell or an array of them, as `str` or as content
222
+ /// - Adds padding cells to maintain consistent column alignment
223
+ ///
224
+ /// Cells may be content, not just `str`: a `plaintext` recipient list lowers
225
+ /// each element to a markup block, which is what makes the rendered glyphs
226
+ /// carry their own spans and so become click-navigable.
227
+ ///
228
+ /// - content (str | content | array): Cell(s) to arrange in grid
229
+ /// - column-gutter (length): Space between columns (default: 0.5em)
230
+ /// - cols (int): Number of columns for the grid (default: 3)
231
+ /// -> grid
232
+ #let create-auto-grid(content, column-gutter: .5em, cols: 3) = {
233
+ let content_type = type(content)
234
+
235
+ // Normalize to 1d array. A lone cell — `str` or content — is a one-cell grid.
236
+ if content_type != array {
237
+ content = (content,)
238
+ }
239
+
240
+
241
+ // Build cell array in row-major order
242
+ let cells = ()
243
+ let i = 0
244
+ for item in content {
245
+ i += 1
246
+ cells.push(item)
247
+ if calc.rem(i, cols) == 0 {
248
+ // Add empty cell to pad the page
249
+ cells.push([])
250
+ }
251
+ }
252
+
253
+ // Add padding cells to complete the last row if needed
254
+ let remainder = calc.rem(cells.len(), cols + 1)
255
+ if remainder != 0 {
256
+ let padding_needed = (cols + 1) - remainder
257
+ for _ in range(padding_needed) {
258
+ cells.push([])
259
+ }
260
+ }
261
+
262
+ grid(
263
+ columns: calc.max(1, cols) + 1,
264
+ column-gutter: .1fr,
265
+ row-gutter: spacing.line,
266
+ ..cells
267
+ )
268
+ }
269
+
270
+ // =============================================================================
271
+ // TYPE NORMALIZATION UTILITIES
272
+ // =============================================================================
273
+
274
+ /// Ensures the input is an array. If already an array, returns as-is.
275
+ /// If not an array, wraps the value in a tuple.
276
+ ///
277
+ /// This utility eliminates repetitive `if type(x) == array` checks throughout
278
+ /// the codebase by providing a canonical "normalize to array" function.
279
+ ///
280
+ /// - value: Any value to normalize to array form
281
+ /// - Returns: Array containing the value(s)
282
+ ///
283
+ /// Examples:
284
+ /// - ensure-array("foo") → ("foo",)
285
+ /// - ensure-array(("a", "b")) → ("a", "b")
286
+ /// - ensure-array(none) → ()
287
+ #let ensure-array(value) = {
288
+ if value == none {
289
+ ()
290
+ } else if type(value) == array {
291
+ value
292
+ } else {
293
+ (value,)
294
+ }
295
+ }
296
+
297
+ /// Normalizes a scalar-or-array field to a single stacked-lines **content**
298
+ /// value, one array element per line.
299
+ ///
300
+ /// This is the content-safe successor to the former `ensure-string`: every
301
+ /// prose field the quill declares is now a `plaintext` / `richtext` field, so
302
+ /// it arrives as Typst *content* (a markup block), and `str(..)` on content is
303
+ /// an error. Joining with `linebreak()` rather than `"\n"` renders the same
304
+ /// stacked lines while accepting either shape.
305
+ ///
306
+ /// A blank content field arrives as `""` and lands as empty content, which
307
+ /// stays `falsey` for callers that test the result.
308
+ ///
309
+ /// - value (any): Scalar or array of scalars/content to stack
310
+ /// -> content
311
+ ///
312
+ /// Examples:
313
+ /// - join-lines("foo") → [foo]
314
+ /// - join-lines(([a], [b])) → [a] + linebreak() + [b]
315
+ /// - join-lines(()) → []
316
+ /// - join-lines(none) → []
317
+ #let join-lines(value) = {
318
+ if value == none { return [] }
319
+ let items = if type(value) == array { value } else { (value,) }
320
+ // A `str` element is wrapped so the join is content-to-content throughout;
321
+ // mixing `str` and content under `+` is not a legal Typst addition.
322
+ let joined = items.map(item => [#item]).join(linebreak())
323
+ // `().join(..)` is `none`, not `[]` — coerce so an empty array stays `falsey`.
324
+ if joined == none { [] } else { joined }
325
+ }
326
+
327
+ /// Extracts the first element from an array, or returns the value if not an array.
328
+ ///
329
+ /// This utility eliminates repetitive ternary operators like
330
+ /// `if type(x) == array { x.at(0) } else { x }` by providing a canonical
331
+ /// "first element or self" function.
332
+ ///
333
+ /// - value: Any value to extract from
334
+ /// - Returns: First array element if array, otherwise the value itself
335
+ ///
336
+ /// Examples:
337
+ /// - first-or-value("foo") → "foo"
338
+ /// - first-or-value(("a", "b")) → "a"
339
+ /// - first-or-value(()) → none
340
+ /// - first-or-value(none) → none
341
+ #let first-or-value(value) = {
342
+ if value == none {
343
+ none
344
+ } else if type(value) == array {
345
+ if value.len() > 0 {
346
+ value.at(0)
347
+ } else {
348
+ none
349
+ }
350
+ } else {
351
+ value
352
+ }
353
+ }
354
+
355
+
356
+ // =============================================================================
357
+ // INDORSEMENT UTILITIES
358
+ // =============================================================================
359
+
360
+ /// Converts number to ordinal suffix for indorsements following AFH 33-337 conventions.
361
+ ///
362
+ /// AFH 33-337 Chapter 14 indorsement examples show "1st Ind", "2d Ind", "3d Ind" format.
363
+ /// Note: Military style uses "2d" and "3d" instead of "2nd" and "3rd" per DoD correspondence standards.
364
+ ///
365
+ /// Generates proper ordinal suffixes for indorsement numbering:
366
+ /// - 1st, 2d, 3d, 4th, 5th, etc. (note: military uses "2d" and "3d", not "2nd" and "3rd")
367
+ /// - Special handling for 11th, 12th, 13th (all use "th")
368
+ /// - Follows official military correspondence standards
369
+ ///
370
+ /// - number (int): The indorsement number (1, 2, 3, etc.)
371
+ /// -> str
372
+ #let get-ordinal-suffix(number) = {
373
+ let last-digit = calc.rem(number, 10)
374
+ let last-two-digits = calc.rem(number, 100)
375
+
376
+ if last-two-digits >= 11 and last-two-digits <= 13 {
377
+ "th"
378
+ } else if last-digit == 1 {
379
+ "st"
380
+ } else if last-digit == 2 {
381
+ "d"
382
+ } else if last-digit == 3 {
383
+ "d"
384
+ } else {
385
+ "th"
386
+ }
387
+ }
388
+
389
+ /// Formats indorsement number according to AFH 33-337 standards.
390
+ ///
391
+ /// Creates properly formatted indorsement labels with ordinal suffixes:
392
+ /// - "1st Ind", "2d Ind", "3d Ind", "4th Ind", etc.
393
+ /// - Uses military-specific ordinal format (2d/3d instead of 2nd/3rd)
394
+ /// - Combines with "Ind" suffix for standard indorsement header format
395
+ ///
396
+ /// - number (int): Indorsement sequence number (1, 2, 3, etc.)
397
+ /// -> str
398
+ #let format-indorsement-number(number) = {
399
+ let suffix = get-ordinal-suffix(number)
400
+ str(number) + suffix + " Ind"
401
+ }
402
+
403
+ /// Processes and renders an array of indorsements.
404
+ ///
405
+ /// Iterates through an array of indorsement objects and renders each one
406
+ /// with proper formatting and font settings. Used by the main memorandum
407
+ /// template to process the indorsements parameter.
408
+ ///
409
+ /// - indorsements (array): Array of indorsement objects created with indorsement()
410
+ /// - body-font (str | array): Font(s) to use for indorsement text
411
+ /// - font-size (length): Font size for indorsement text (default: 12pt)
412
+ /// -> content
413
+ #let process-indorsements(indorsements, body-font: none, font-size: 12pt) = {
414
+ if not falsey(indorsements) {
415
+ for indorsement in indorsements {
416
+ (indorsement.render)(body-font: body-font, font-size: font-size)
417
+ }
418
+ }
419
+ }
@@ -0,0 +1,27 @@
1
+ [package]
2
+ name = "tonguetoquill-usaf-memo"
3
+ version = "4.0.0"
4
+ compiler = "0.14.0"
5
+ entrypoint = "src/lib.typ"
6
+ repository = "https://github.com/nibsbin/tonguetoquill-usaf-memo"
7
+ authors = ["Nibs"]
8
+ license = "MIT"
9
+ description = "Typeset memos that are fully compliant with AFH 33-337 'The Tongue and Quill'."
10
+ keywords = [
11
+ "USAF",
12
+ "Air Force",
13
+ "memorandum",
14
+ "memo",
15
+ "military",
16
+ "AFH 33-337",
17
+ "official",
18
+ "correspondence",
19
+ "formatting",
20
+ ]
21
+ categories = ["office", "report"]
22
+ disciplines = ["politics", "law", "business", "engineering", "education"]
23
+
24
+ [template]
25
+ path = "template"
26
+ entrypoint = "usaf-template.typ"
27
+ thumbnail = "template/assets/thumbnail.png"
@@ -0,0 +1,127 @@
1
+ #import "@local/quillmark-helper:0.1.0": data, signature-field
2
+ #import "@local/tonguetoquill-usaf-memo:4.0.0": backmatter, frontmatter, indorsement, mainmatter
3
+
4
+ // The letterhead arrives as one ordered array of lines. Element 0 is the title,
5
+ // set larger; the rest are caption lines beneath it. A single element renders
6
+ // the title alone — the package skips a falsey caption. An empty array renders
7
+ // no letterhead text at all, silently: the block is `place`d, so an empty title
8
+ // leaves no glyphs and takes no space from the flow, and the seal stands alone.
9
+ #let letterhead_lines = data.letterhead_title
10
+
11
+ // Frontmatter configuration
12
+ #show: frontmatter.with(
13
+ // Letterhead configuration
14
+ letterhead_title: letterhead_lines.at(0, default: ""),
15
+ letterhead_caption: if letterhead_lines.len() > 1 { letterhead_lines.slice(1) } else { () },
16
+ letterhead_seal_subtitle: data.at("letterhead_seal_subtitle", default: none),
17
+ letterhead_seal: image(
18
+ if data.at("letterhead_seal", default: "dow") == "dod" {
19
+ "assets/dod_seal.png"
20
+ } else {
21
+ "assets/dow_seal.png"
22
+ }
23
+ ),
24
+
25
+ // Date
26
+ date: data.at("date", default: none),
27
+
28
+ // Receiver information
29
+ memo_for: data.memo_for,
30
+
31
+ // Sender information (omitted for Memorandum for Record)
32
+ ..if data.at("memo_from", default: ()).len() > 0 { (memo_from: data.memo_from) },
33
+
34
+ // Subject line
35
+ subject: data.subject,
36
+
37
+ // Optional references
38
+ ..if "references" in data { (references: data.references) },
39
+
40
+ // Optional footer tag line
41
+ ..if "tag_line" in data { (footer_tag_line: data.tag_line) },
42
+
43
+ // Optional classification level
44
+ ..if "classification" in data { (classification_level: data.classification) },
45
+
46
+ ..if "dissemination" in data { (dissemination: data.dissemination) },
47
+
48
+ // CUI designation indicator block fields (DoDM 5200.48)
49
+ ..if "cui_controlled_by" in data { (cui_controlled_by: data.cui_controlled_by) },
50
+ ..if "cui_category" in data { (cui_category: data.cui_category) },
51
+ ..if "cui_limited_dissemination" in data { (cui_limited_dissemination: data.cui_limited_dissemination) },
52
+ ..if "cui_poc" in data { (cui_poc: data.cui_poc) },
53
+
54
+ // USAF vs DAF memorandum style (date format, body indentation)
55
+ memo_style: data.at("memo_style", default: "usaf"),
56
+
57
+ // Font size
58
+ font_size: data.at("font_size", default: 12) * 1pt,
59
+
60
+ // List recipients in vertical list
61
+ memo_for_cols: 1,
62
+ )
63
+
64
+ // Mainmatter. The body's region needs no recovery step here: the package's
65
+ // render-body rebuilds paragraphs through a state buffer (AFH 33-337
66
+ // auto-numbering), but the rebuilt glyphs keep their spans, which is what
67
+ // the backend reads regions from.
68
+ #mainmatter[
69
+ #data.at("$body")
70
+ ]
71
+
72
+ // Backmatter
73
+ #backmatter(
74
+ // Signature block
75
+ signature_block: data.signature_block,
76
+ signing_field: signature-field("Signature", field: "signature_block"),
77
+
78
+ // Optional cc
79
+ ..if "cc" in data { (cc: data.cc) },
80
+
81
+ // Optional distribution
82
+ ..if "distribution" in data { (distribution: data.distribution) },
83
+
84
+ // Optional attachments
85
+ ..if "attachments" in data { (attachments: data.attachments) },
86
+ )
87
+
88
+ // Indorsements - iterate through CARDS array and filter by CARD tag
89
+ #for (i, card) in data.at("$cards").enumerate() {
90
+ if card.at("$kind") == "indorsement" {
91
+ // The quillmark helper leaves an unset/whitespace-only markdown body as
92
+ // the empty string `""`; only non-empty bodies are eval'd into content.
93
+ // Pass truly empty content (`[]`) in the empty case so indorsement can
94
+ // collapse the body's surrounding spacing.
95
+ let body = card.at("$body", default: "")
96
+ let body_content = if type(body) == str { [] } else { body }
97
+ // Per AFH 33-337 Ch. 14, an indorsement is dated when the endorser signs
98
+ // it (distinct from the originating memo's date). The signing date is
99
+ // generally unknown at compile time and filled in by hand, so a blank or
100
+ // omitted date renders a fill-in line rather than stamping the compile date.
101
+ let card_date = card.at("date", default: none)
102
+ let resolved_date = if card_date == none or card_date == "" {
103
+ none
104
+ } else {
105
+ card_date
106
+ }
107
+ // The card's `$path` prefix composes its canonical schema addresses
108
+ // (`$cards.indorsement.<n>.…`, per-kind ordinal) — the absolute loop
109
+ // index `i` is NOT that ordinal once kinds interleave, so it stays a
110
+ // widget-name suffix only. The card body's region rides its own glyph
111
+ // spans through the package rebuild, per-card because each card's body
112
+ // has its own backend-generated eval site.
113
+ indorsement(
114
+ from: card.at("from", default: ""),
115
+ to: card.at("for", default: ""),
116
+ signature_block: card.signature_block,
117
+ signing_field: signature-field(
118
+ "Ind_" + str(i) + "_Signature",
119
+ field: card.at("$path") + "signature_block",
120
+ ),
121
+ format: card.at("format", default: "standard"),
122
+ date: resolved_date,
123
+ ..if "action" in card { (action: card.action) },
124
+ body_content,
125
+ )
126
+ }
127
+ }