@airmark/quiver 0.32.2 → 0.34.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 (90) hide show
  1. package/README.md +63 -21
  2. package/package.json +9 -7
  3. package/quills/af4141/0.1.0/Quill.yaml +4 -13
  4. package/quills/af4141/0.1.0/example.md +53 -0
  5. package/quills/afmc_moa/0.0.1/Quill.yaml +39 -62
  6. package/quills/afmc_moa/0.0.1/example.md +114 -0
  7. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/config.typ +1 -1
  8. package/quills/classic_resume/0.0.1/Quill.yaml +281 -0
  9. package/quills/classic_resume/0.0.1/example.md +111 -0
  10. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/LICENSE +21 -0
  11. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Bold.ttf +0 -0
  12. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-BoldItalic.ttf +0 -0
  13. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Italic.ttf +0 -0
  14. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Regular.ttf +0 -0
  15. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/OFL.txt +94 -0
  16. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/components.typ +214 -0
  17. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/config.typ +61 -0
  18. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/layout.typ +86 -0
  19. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/lib.typ +7 -0
  20. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/util.typ +37 -0
  21. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/typst.toml +26 -0
  22. package/quills/classic_resume/0.0.1/plate.typ +143 -0
  23. package/quills/cyber_ribbon_chart/0.0.1/Quill.yaml +327 -0
  24. package/quills/cyber_ribbon_chart/0.0.1/example.md +55 -0
  25. package/quills/cyber_ribbon_chart/0.0.1/plate.typ +609 -0
  26. package/quills/daf1206/0.1.0/Quill.yaml +15 -18
  27. package/quills/daf1206/0.1.0/example.md +55 -0
  28. package/quills/daf4392/0.1.0/Quill.yaml +16 -7
  29. package/quills/daf4392/0.1.0/example.md +51 -0
  30. package/quills/daf4392/0.1.0/plate.typ +2 -2
  31. package/quills/usaf_letter/0.1.0/Quill.yaml +179 -0
  32. package/quills/usaf_letter/0.1.0/assets/dod_seal.png +0 -0
  33. package/quills/usaf_letter/0.1.0/assets/dow_seal.png +0 -0
  34. package/quills/usaf_letter/0.1.0/example.md +35 -0
  35. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/.gitignore +7 -0
  36. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/LICENSE +201 -0
  37. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/CopperplateCC-Bold.otf +0 -0
  38. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/LICENSE.md +79 -0
  39. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LICENSE +102 -0
  40. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LiberationMono-Regular.ttf +0 -0
  41. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
  42. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
  43. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
  44. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
  45. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
  46. package/quills/{usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE → usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/OFL.txt} +2 -2
  47. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
  48. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
  49. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/backmatter.typ +32 -0
  50. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/config.typ +45 -0
  51. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/frontmatter.typ +226 -0
  52. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/lib.typ +35 -0
  53. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/mainmatter.typ +140 -0
  54. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/primitives.typ +544 -0
  55. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/utils.typ +334 -0
  56. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/typst.toml +22 -0
  57. package/quills/usaf_letter/0.1.0/plate.typ +121 -0
  58. package/quills/usaf_memo/0.2.0/Quill.yaml +8 -41
  59. package/quills/usaf_memo/0.2.0/example.md +45 -0
  60. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
  61. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/typst.toml +1 -1
  62. package/quills/usaf_memo/0.3.0/Quill.yaml +51 -69
  63. package/quills/usaf_memo/0.3.0/example.md +48 -0
  64. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
  65. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/OFL.txt +93 -0
  66. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
  67. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
  68. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +29 -17
  69. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +97 -126
  70. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/config.typ +12 -45
  71. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +81 -87
  72. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +58 -66
  73. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +14 -36
  74. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +47 -19
  75. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +158 -111
  76. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +41 -141
  77. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/typst.toml +3 -8
  78. package/quills/usaf_memo/0.3.0/plate.typ +78 -85
  79. package/templates/af4141.md +23 -0
  80. package/templates/appointment_letter.md +58 -0
  81. package/templates/daf1206.md +23 -0
  82. package/templates/daf4392.md +34 -0
  83. package/templates/deros_extension.md +40 -0
  84. package/templates/pass_request.md +48 -0
  85. package/templates/rebuttal.md +63 -0
  86. package/templates/templates.json +65 -0
  87. package/templates/usaf_template.md +30 -0
  88. package/templates/ussf_template.md +30 -0
  89. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel[wght].ttf +0 -0
  90. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
@@ -0,0 +1,544 @@
1
+ // One rendering function per section of the memorandum, each placing its
2
+ // section where AFH 33-337 Chapter 14 puts it.
3
+
4
+ #import "config.typ": *
5
+ #import "utils.typ": *
6
+
7
+ // AFH 33-337 does not specify letterhead placement; the geometry below follows
8
+ // standard USAF memo convention.
9
+ #let render-letterhead(
10
+ title,
11
+ caption,
12
+ font,
13
+ letterhead-seal: none,
14
+ letterhead-seal-subtitle: none,
15
+ letterhead-emblem: none, // optional image placed opposite the seal (right side)
16
+ letterhead-emblem-height: 1in, // emblem fit-box height; reduce for shorter emblems
17
+ ) = {
18
+ font = ensure-array(font)
19
+ // `upper` takes content as readily as `str`, so the letterhead's uppercasing
20
+ // survives a title or caption handed over as markup.
21
+ title = upper(join-lines(title))
22
+ caption = upper(join-lines(caption))
23
+
24
+ // Letterhead corner geometry. The seal (left) and emblem (right) share one
25
+ // reference band so the corners stay in parity: both bleed `corner-overhang`
26
+ // past the page margin and center on the same axis (`band-center`). The
27
+ // emblem may be shorter than the band but stays centered on that axis.
28
+ let corner-overhang = 0.5in
29
+ let corner-width = 2in
30
+ let band-height = 1in // seal height; also the emblem's reference band
31
+ let band-top = -band-height / 2 // puts the band center at dy 0
32
+ let band-center = band-top + band-height / 2
33
+
34
+ // The centered title/caption is placed content (not width-constrained), so a
35
+ // long caption would otherwise render on one line and run underneath the seal.
36
+ // Bound the caption to the width that stays clear of the seal on both sides
37
+ // and auto-shrink it to fit: the seal reaches `band-height - corner-overhang`
38
+ // into the text area, so reserving that plus a gutter on each side keeps the
39
+ // centered caption from touching the seal. `layout` resolves the text-area
40
+ // width so the bound tracks the page/margins.
41
+ let caption-gutter = 0.25in
42
+ let caption-clear = (band-height - corner-overhang) + caption-gutter
43
+ place(
44
+ dy: 0.625in - spacing.margin,
45
+ box(
46
+ width: 100%,
47
+ fill: none,
48
+ stroke: none,
49
+ layout(size => place(
50
+ center + top,
51
+ align(center)[
52
+ #set text(12pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")
53
+ #title\
54
+ #v(1pt)
55
+ #if not falsey(caption) {
56
+ fit-to-width(
57
+ size.width - 2 * caption-clear,
58
+ alignment: center,
59
+ box(text(10.5pt)[#caption]),
60
+ )
61
+ }
62
+ ],
63
+ )),
64
+ ),
65
+ )
66
+
67
+ if letterhead-seal != none {
68
+ let seal-body = if falsey(letterhead-seal-subtitle) {
69
+ block[
70
+ #fit-box(width: corner-width, height: band-height)[#letterhead-seal]
71
+ ]
72
+ } else {
73
+ // Isolate seal column from document `font-size`: stack `em` spacing and subtitle
74
+ // must not scale with body text (see frontmatter `set text(size: font-size)`).
75
+ // Subtitle is wrapped in `box` so it stays on one line and may extend past
76
+ // the seal's 2in column rather than wrapping.
77
+ block[
78
+ #set text(9pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")
79
+ // Spacing applies between positional stack children only, not one `[…]` body.
80
+ #stack(
81
+ spacing: 0.5em,
82
+ fit-box(width: corner-width, height: band-height)[#letterhead-seal],
83
+ box(upper(join-lines(letterhead-seal-subtitle))),
84
+ )
85
+ ]
86
+ }
87
+ place(
88
+ left + top,
89
+ dx: -corner-overhang,
90
+ dy: band-top,
91
+ seal-body,
92
+ )
93
+ }
94
+
95
+ if letterhead-emblem != none {
96
+ // Mirror the seal: same overhang and width, centered on the seal's band
97
+ // axis. Placing the emblem's top at `band-center - height/2` keeps its
98
+ // center on `band-center` for any (possibly shorter) emblem height.
99
+ place(
100
+ right + top,
101
+ dx: corner-overhang,
102
+ dy: band-center - letterhead-emblem-height / 2,
103
+ block[
104
+ #fit-box(width: corner-width, height: letterhead-emblem-height, alignment: right + horizon)[#letterhead-emblem]
105
+ ],
106
+ )
107
+ }
108
+ }
109
+
110
+ // AFH 33-337 "Date": "Place the date 1 inch from the right edge, 1.75 inches from the top"
111
+ #let render-date-section(date, memo-style: "usaf") = {
112
+ align(right)[#display-date(date, memo-style: memo-style)]
113
+ }
114
+
115
+ // AFH 33-337 "MEMORANDUM FOR": "Place 'MEMORANDUM FOR' on the second line below the date"
116
+ #let render-for-section(recipients, cols) = {
117
+ blank-line()
118
+ grid(
119
+ columns: (auto, auto, 1fr),
120
+ "MEMORANDUM FOR",
121
+ " ",
122
+ align(left)[
123
+ #if type(recipients) == array {
124
+ create-auto-grid(recipients.map(upper), column-gutter: spacing.tab, cols: cols)
125
+ } else {
126
+ upper(recipients)
127
+ }
128
+ ],
129
+ )
130
+ }
131
+
132
+ // AFH 33-337 "FROM:": "Place 'FROM:' in uppercase, flush with the left margin,
133
+ // on the second line below the last line of the MEMORANDUM FOR element"
134
+ #let render-from-section(from-info) = {
135
+ blank-line()
136
+ from-info = join-lines(from-info)
137
+
138
+ grid(
139
+ columns: (auto, auto, 1fr),
140
+ "FROM:", " ", align(left)[#from-info],
141
+ )
142
+ }
143
+
144
+ /// Whether a `references` entry carries no citation text.
145
+ ///
146
+ /// Blank entries reach the template routinely — a stub entry left under
147
+ /// `references:` for the user to fill in. Such an entry must not count as a
148
+ /// reference: a lone blank one would otherwise satisfy the "exactly one
149
+ /// reference" test below and render as an empty `()` after the subject.
150
+ ///
151
+ /// - entry (any): A single `references` element
152
+ /// -> bool
153
+ #let blank-reference(entry) = falsey(entry) or entry == []
154
+
155
+ /// Drops `references` entries that carry no citation text and normalizes the
156
+ /// result to an array, so blank placeholder entries neither render on their
157
+ /// own nor affect the inline-vs-block decision.
158
+ ///
159
+ /// - references (array | none): Raw reference entries
160
+ /// -> array
161
+ #let compact-references(references) = ensure-array(references).filter(entry => not blank-reference(entry))
162
+
163
+ // AFH 33-337 "SUBJECT:": "In all uppercase letters place 'SUBJECT:', flush with the
164
+ // left margin, on the second line below the last line of the FROM element"
165
+ #let render-subject-section(subject-text, inline-reference: none) = {
166
+ blank-line()
167
+ let content = if not blank-reference(inline-reference) {
168
+ // `subject-text` is a content field, so it is boxed for the same reason the
169
+ // inline reference is: a markup block's edge newlines would otherwise read
170
+ // as a space, doubling the one before the parenthesis.
171
+ [#box(subject-text) (#box(inline-reference))]
172
+ } else {
173
+ [#subject-text]
174
+ }
175
+ grid(
176
+ columns: (auto, auto, 1fr),
177
+ "SUBJECT:", " ", content,
178
+ )
179
+ }
180
+
181
+ // AFH 33-337: only render References block for two or more references.
182
+ // A single reference is rendered inline after the SUBJECT text instead.
183
+ #let render-references-section(references) = {
184
+ let references = compact-references(references)
185
+ if references.len() >= 2 {
186
+ blank-line()
187
+ grid(
188
+ columns: (auto, auto, 1fr),
189
+ // Spread the entries as enum items lettered "(a) (b) (c)" per AFH 33-337.
190
+ "References:", " ", enum(..references, numbering: "(a) ", body-indent: 0pt),
191
+ )
192
+ }
193
+ }
194
+
195
+ // AFH 33-337 "Signature Block": "Start the signature block on the fifth line below
196
+ // the last line of text and 4.5 inches from the left edge of the page or three
197
+ // spaces to the right of page center"
198
+ // AFH 33-337 "Do not place the signature element on a continuation page by itself"
199
+ // AFH 33-337 long-name example: "Signature block adjusted to the left" when a
200
+ // long name would otherwise exceed the right margin.
201
+ //
202
+ // A closing line may open the section above the block, and AFH 33-337 gives it
203
+ // one geometry across the two documents that have one: on the second line below
204
+ // the text at the block's own anchor, with the signature then five lines below
205
+ // the line rather than below the body. The memorandum's occupant is the
206
+ // authority line, "FOR THE COMMANDER"; the personal letter's is the
207
+ // complimentary close, "Sincerely", which the handbook bars the authority line
208
+ // from. Hence the neutral parameter, and hence case belongs to the caller — the
209
+ // authority line is uppercased, "Sincerely" is not.
210
+
211
+ /// The memorandum's occupant of that slot, uppercased per AFH 33-337. Blank is
212
+ /// no line: the handbook forbids one where the commander signs.
213
+ ///
214
+ /// - value (str | content | none): The authority line as authored
215
+ /// -> content | none
216
+ #let format-authority-line(value) = if falsey(value) { none } else { upper(value) }
217
+
218
+ #let render-signature-block(
219
+ signature-lines,
220
+ closing-line: none,
221
+ signature-blank-lines: 4,
222
+ signing-field: none,
223
+ // Lines of breaking height reserved below the block for the backmatter
224
+ // lead-in and its continuation note. Reclaimed immediately after, so it moves
225
+ // where the block may break and nothing else.
226
+ reserved-lines: 0,
227
+ ) = {
228
+ signature-lines = ensure-array(signature-lines)
229
+ // Blank is no line, whichever occupant the caller passes.
230
+ if falsey(closing-line) { closing-line = none }
231
+ // AFH 33-337 allows two equivalent anchors: 4.5in from the left edge, or three
232
+ // spaces right of page center. On 8.5in stock these coincide (page center =
233
+ // 4.25in; three TNR-12pt spaces ≈ 0.25in), so we use 4.5in as the canonical
234
+ // anchor. pad() is relative to the text area, hence (4.5in - margin).
235
+ let default-pad = 4.5in - spacing.margin
236
+ context {
237
+ // Measure each line at its rendered settings to detect long-name overflow.
238
+ // The closing line shares the anchor, so it joins the measurement: the
239
+ // wider of the two decides the shift and they stay aligned.
240
+ let body-width = page.width - 2 * spacing.margin
241
+ let anchored-lines = signature-lines
242
+ if closing-line != none { anchored-lines.push(closing-line) }
243
+ let widest = 0pt
244
+ for line in anchored-lines {
245
+ let w = measure(text(hyphenate: false, line)).width
246
+ if w > widest { widest = w }
247
+ }
248
+ let stride = line-stride()
249
+ // If the widest line would overflow the right margin at the standard
250
+ // anchor, shift the block left just enough to fit. Clamp at 0 so the
251
+ // block never crosses the left margin.
252
+ let available = body-width - default-pad
253
+ let left-pad = if widest > available {
254
+ let shifted = body-width - widest
255
+ if shifted < 0pt { 0pt } else { shifted }
256
+ } else {
257
+ default-pad
258
+ }
259
+ block(breakable: false)[
260
+ #let gap = stride * signature-blank-lines
261
+ // AFH 33-337: "fifth line below the last line of text" = four blank lines
262
+ // between the text and the signature block. Carried INSIDE the
263
+ // unbreakable block rather than emitted ahead of it, which keeps the gap
264
+ // and the block one indivisible unit. The total space above the block is
265
+ // identical either way — `block.above` still contributes the same 0.5em —
266
+ // but a gap left outside can be consumed at the foot of one page while
267
+ // the block starts at the top margin of the next.
268
+ //
269
+ // The closing line takes one of those lines above it and the gap then
270
+ // measures from the line, not from the text. Inside the block, so no page
271
+ // break can put it on a different page from the signature.
272
+ #if closing-line != none {
273
+ v(stride)
274
+ pad(left: left-pad, text(hyphenate: false, closing-line))
275
+ }
276
+ #if signing-field != none {
277
+ // The signing field covers those blank lines — where a signature is
278
+ // actually written — so it is placed over the gap. It is placed BEFORE
279
+ // the gap is emitted: `place` anchors at the current flow position, so
280
+ // placing it after `v(gap)` anchors the box at the first name line and
281
+ // paints it down over the printed signature block. Anchored here it
282
+ // still travels with the block onto whatever page the block lands on,
283
+ // since the gap is inside the unbreakable block rather than ahead of
284
+ // it. The current position is below the closing line when there is
285
+ // one, which is where the signature is written.
286
+ //
287
+ // The widget keeps its own size (the helper's default is 50pt tall and
288
+ // it positions itself, so an `align` around it does nothing) and is
289
+ // offset to the BOTTOM of the gap: it always ends where the printed
290
+ // name begins, and whatever the gap has beyond the widget's height
291
+ // stays clear between the body text and the widget's frame.
292
+ let widget-height = {
293
+ let h = measure(signing-field).height
294
+ if h > 0pt { h } else { 50pt }
295
+ }
296
+ let drop = gap - widget-height - 3pt
297
+ place(
298
+ dx: left-pad,
299
+ dy: if drop > 0pt { drop } else { 0pt },
300
+ box(width: body-width - left-pad, height: widget-height, signing-field),
301
+ )
302
+ }
303
+ #v(gap)
304
+ #align(left)[
305
+ #pad(left: left-pad)[
306
+ #text(hyphenate: false)[
307
+ #for line in signature-lines {
308
+ // AFH 33-337: "indent the next line to begin under the third character
309
+ // of the line above" — 2-character indent ≈ 1em in Times New Roman 12pt
310
+ par(hanging-indent: .5em, line)
311
+ }
312
+ // The page the backmatter compares its own against. Must stay
313
+ // inside the unbreakable block: a marker outside one travels with
314
+ // the section that moves.
315
+ #metadata(none)<usaf-memo-flow-anchor>
316
+ #v(stride * reserved-lines)
317
+ ]
318
+ ]
319
+ ]
320
+ ]
321
+ // The reservation counted toward breaking height only; the backmatter
322
+ // emits its own lead-in below.
323
+ v(stride * -reserved-lines)
324
+ }
325
+ }
326
+
327
+ // The indorsement decision line is an "either / or" pair. Which pair prints
328
+ // follows from the indorsement's role in the coordination chain rather than
329
+ // from the endorser's choice, so the caller supplies the role and the same
330
+ // three `action` values carry over both pairs. The caller also suppresses the
331
+ // line entirely; reaching here means one is wanted.
332
+
333
+ // The option pair for each role, ordered (affirmative, negative).
334
+ #let APPROVAL_OPTIONS = ("Approve", "Disapprove")
335
+ #let COORDINATION_OPTIONS = ("Concur", "Nonconcur")
336
+
337
+ // Maps each `action` value to the index of the option it selects within
338
+ // whichever pair is rendered (`none` selects neither).
339
+ #let ACTION_SELECTIONS = (
340
+ "undecided": none,
341
+ "approve": 0,
342
+ "disapprove": 1,
343
+ )
344
+
345
+ #let render-action-line(action, approval-authority: false, trailing-blank-line: true) = {
346
+ assert(
347
+ action in ACTION_SELECTIONS,
348
+ message: "action must be one of " + ACTION_SELECTIONS.keys().map(k => "\"" + k + "\"").join(", "),
349
+ )
350
+ let options = if approval-authority { APPROVAL_OPTIONS } else { COORDINATION_OPTIONS }
351
+ let selected = ACTION_SELECTIONS.at(action)
352
+ // Underline the selected option; strike the one it was chosen over. When
353
+ // neither is selected both render plain. An underline keeps the option on
354
+ // the line's own baseline — the box it replaces had to be nudged with
355
+ // `baseline` to sit straight — and reads as a mark made on the page, where a
356
+ // ruled rectangle in a PDF carrying real AcroForm widgets reads as one more
357
+ // fillable field.
358
+ let render-option(index) = {
359
+ let option = options.at(index)
360
+ if selected == none {
361
+ option
362
+ } else if selected == index {
363
+ underline(option)
364
+ } else {
365
+ strike(option)
366
+ }
367
+ }
368
+ // No leading blank-line: the caller (indorsement.typ) already emits the
369
+ // header→content gap once. The action line's `block(sticky: true)`
370
+ // additionally inherits `block.above: spacing.line` so the visual gap
371
+ // above matches the gap above a body's first paragraph.
372
+ //
373
+ // Keep the action line with the following content (body or signature block)
374
+ // using the same sticky-block pattern that body.typ applies to the last
375
+ // paragraph, per AFH 33-337 §11 orphan-prevention rules.
376
+ block(sticky: true)[#render-option(0) / #render-option(1)]
377
+ // Trailing blank-line places the body's first paragraph one line below
378
+ // the action, mirroring the gap above it. Suppressed when the body is
379
+ // empty so the signature block's own 4-line gap lands on AFH 33-337's
380
+ // "fifth line below the last line of text" anchor.
381
+ if trailing-blank-line {
382
+ blank-line()
383
+ }
384
+ }
385
+
386
+ /// Renders a table in the memorandum's style.
387
+ ///
388
+ /// AFH 33-337 does not specify table formatting, so this follows the general
389
+ /// aesthetic of the standard: plain black borders, a bold header row, no
390
+ /// decorative fills, and the body font and size inherited from the surrounding
391
+ /// text.
392
+ ///
393
+ /// - it (content): The table element to style and render
394
+ /// -> content
395
+ #let render-memo-table(it) = {
396
+ show table.cell.where(y: 0): set text(weight: "bold")
397
+ set table(
398
+ stroke: 0.5pt + black,
399
+ inset: (x: 0.5em, y: 0.4em),
400
+ )
401
+ it
402
+ }
403
+
404
+ #let render-backmatter-section(
405
+ content,
406
+ section-label,
407
+ numbering-style: none,
408
+ continuation-label: none,
409
+ // Lines of breaking height reserved below this section for the lead-in and
410
+ // continuation note of the section that follows it. Reclaimed immediately
411
+ // after, so it moves where a break may fall and nothing else.
412
+ reserved-lines: 0,
413
+ ) = {
414
+ let formatted-content = {
415
+ // `text()` keeps the label from being laid out as a paragraph of its own.
416
+ text()[#section-label]
417
+ linebreak()
418
+ if numbering-style != none {
419
+ let items = ensure-array(content)
420
+ enum(..items, numbering: numbering-style)
421
+ } else {
422
+ join-lines(content)
423
+ }
424
+ }
425
+
426
+ // Attachments pass continuation-label ("… (listed on next page):" per AFH 33-337).
427
+ // cc: and DISTRIBUTION: use a neutral default — "listed" applies to attachment lists only.
428
+ let continuation-text = if continuation-label != none {
429
+ text()[#continuation-label]
430
+ } else {
431
+ text()[#(section-label + " (continued on next page)")]
432
+ }
433
+
434
+ context {
435
+ // `breakable: false` lets Typst's own breaker move the section to the next
436
+ // page as a unit; an explicit pagebreak here would feed this context's
437
+ // layout query back into its own input and the two would chase each other
438
+ // until layout gave up.
439
+ //
440
+ // The note is decided by observing where that breaker put the block. The
441
+ // nearest anchor above is the signature block or the preceding section — an
442
+ // element with real height that stays on the page this section departs.
443
+ //
444
+ // Do not reduce this to a `here()` reading. `here()` sits in the section and
445
+ // travels with it, so once the section has moved it reports the top of the
446
+ // page it moved to and every section measures as fitting. The room the note
447
+ // needs is reserved inside the anchor's own unbreakable block, so the
448
+ // decision can never move its own input.
449
+ let above = query(selector(<usaf-memo-flow-anchor>).before(here()))
450
+ let below = query(selector(<usaf-memo-flow-anchor>).after(here()))
451
+ if above.len() > 0 and below.len() > 0 {
452
+ if below.first().location().page() > above.last().location().page() {
453
+ continuation-text
454
+ }
455
+ }
456
+ // A section taller than a page cannot move as a unit, so it splits where
457
+ // the page ends rather than overflowing it, and anchors its last page too:
458
+ // the section below measures from where this one ends.
459
+ let reserve = line-stride() * reserved-lines
460
+ layout(region => {
461
+ let oversized = measure(formatted-content, width: region.width).height + reserve > region.height
462
+ block(breakable: oversized)[
463
+ #metadata(none)<usaf-memo-flow-anchor>
464
+ #formatted-content
465
+ #if oversized [#metadata(none)<usaf-memo-flow-anchor>]
466
+ #v(reserve)
467
+ ]
468
+ })
469
+ v(-reserve)
470
+ }
471
+ }
472
+
473
+ #let calculate-backmatter-spacing(is-first-section) = {
474
+ context {
475
+ let line-count = if is-first-section { 2 } else { 1 }
476
+ blank-lines(line-count)
477
+ }
478
+ }
479
+
480
+ // AFH 33-337 "Attachment or Attachments": "Place 'Attachment:' (for a single attachment)
481
+ // or '# Attachments:' (for two or more attachments) at the left margin, on the third
482
+ // line below the signature element"
483
+ // AFH 33-337 "Courtesy Copy Element": "place 'cc:' flush with the left margin, on the
484
+ // second line below the attachment element"
485
+
486
+ #let render-backmatter-sections(
487
+ attachments: none,
488
+ cc: none,
489
+ distribution: none,
490
+ leading-pagebreak: false,
491
+ ) = {
492
+ let has-backmatter = (
493
+ (attachments != none and attachments.len() > 0)
494
+ or (cc != none and cc.len() > 0)
495
+ or (distribution != none and distribution.len() > 0)
496
+ )
497
+
498
+ if leading-pagebreak and has-backmatter {
499
+ pagebreak(weak: true)
500
+ }
501
+
502
+ // Collected first because a section's reservation depends on whether another
503
+ // follows it: with nothing below, no note can be owed.
504
+ let sections = ()
505
+
506
+ if attachments != none and attachments.len() > 0 {
507
+ let attachment-count = attachments.len()
508
+ let section-label = if attachment-count == 1 { "Attachment:" } else { str(attachment-count) + " Attachments:" }
509
+ let continuation-label = (
510
+ (if attachment-count == 1 { "Attachment" } else { str(attachment-count) + " Attachments" })
511
+ + " (listed on next page):"
512
+ )
513
+ sections.push((
514
+ content: attachments,
515
+ label: section-label,
516
+ // AFH 33-337: a single attachment is not numbered; numbering applies to two or more.
517
+ numbering-style: if attachment-count == 1 { none } else { "1." },
518
+ continuation-label: continuation-label,
519
+ ))
520
+ }
521
+
522
+ if cc != none and cc.len() > 0 {
523
+ sections.push((content: cc, label: "cc:", numbering-style: none, continuation-label: none))
524
+ }
525
+
526
+ if distribution != none and distribution.len() > 0 {
527
+ sections.push((content: distribution, label: "DISTRIBUTION:", numbering-style: none, continuation-label: none))
528
+ }
529
+
530
+ for (index, section) in sections.enumerate() {
531
+ calculate-backmatter-spacing(index == 0)
532
+ // The next section's one blank line of lead-in, plus one for its note. The
533
+ // signature block reserves 3: the first section's lead-in is two lines.
534
+ let reserved-lines = if index + 1 < sections.len() { 2 } else { 0 }
535
+ render-backmatter-section(
536
+ section.content,
537
+ section.label,
538
+ numbering-style: section.numbering-style,
539
+ continuation-label: section.continuation-label,
540
+ reserved-lines: reserved-lines,
541
+ )
542
+ }
543
+ }
544
+