@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.
- package/README.md +3 -2
- package/package.json +3 -3
- package/quills/daf4392/0.1.0/Quill.yaml +2 -2
- package/quills/usaf_memo/0.2.0/Quill.yaml +14 -11
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/body.typ +37 -15
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +5 -6
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +5 -3
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +87 -35
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/utils.typ +34 -1
- package/quills/usaf_memo/0.3.0/Quill.yaml +316 -0
- package/quills/usaf_memo/0.3.0/assets/dod_seal.png +0 -0
- package/quills/usaf_memo/0.3.0/assets/dow_seal.png +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/.gitignore +7 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/LICENSE +21 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel[wght].ttf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE +93 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Bold.otf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/LICENSE.md +79 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/LiberationMono/LICENSE +102 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/LiberationMono/LiberationMono-Regular.ttf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +29 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +364 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/config.typ +78 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +242 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +170 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +57 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +27 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +462 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +419 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/typst.toml +27 -0
- package/quills/usaf_memo/0.3.0/plate.typ +127 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// primitives.typ: Reusable rendering primitives for USAF memorandum sections
|
|
2
|
+
//
|
|
3
|
+
// This module implements the visual rendering functions that produce AFH 33-337
|
|
4
|
+
// compliant formatting for all sections of a USAF memorandum. Each function
|
|
5
|
+
// corresponds to specific placement and formatting requirements from Chapter 14.
|
|
6
|
+
|
|
7
|
+
#import "config.typ": *
|
|
8
|
+
#import "utils.typ": *
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// LETTERHEAD RENDERING
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// AFH 33-337 §1: "Use printed letterhead, computer-generated letterhead, or plain bond paper"
|
|
14
|
+
// Letterhead placement is not explicitly specified in AFH 33-337, but follows
|
|
15
|
+
// standard USAF memo formatting conventions
|
|
16
|
+
|
|
17
|
+
#let render-letterhead(
|
|
18
|
+
title,
|
|
19
|
+
caption,
|
|
20
|
+
font,
|
|
21
|
+
letterhead-seal: none,
|
|
22
|
+
letterhead-seal-subtitle: none,
|
|
23
|
+
letterhead-emblem: none, // optional image placed opposite the seal (right side)
|
|
24
|
+
letterhead-emblem-height: 1in, // emblem fit-box height; reduce for shorter emblems
|
|
25
|
+
) = {
|
|
26
|
+
font = ensure-array(font)
|
|
27
|
+
// `upper` lowers content as readily as `str`, so the letterhead's uppercasing
|
|
28
|
+
// survives the title/caption arriving as `plaintext` markup blocks.
|
|
29
|
+
title = upper(join-lines(title))
|
|
30
|
+
caption = upper(join-lines(caption))
|
|
31
|
+
|
|
32
|
+
// Letterhead corner geometry. The seal (left) and emblem (right) share one
|
|
33
|
+
// reference band so the corners stay in parity: both bleed `corner-overhang`
|
|
34
|
+
// past the page margin and center on the same axis (`band-center`). The
|
|
35
|
+
// emblem may be shorter than the band but stays centered on that axis.
|
|
36
|
+
let corner-overhang = 0.5in
|
|
37
|
+
let corner-width = 2in
|
|
38
|
+
let band-height = 1in // seal height; also the emblem's reference band
|
|
39
|
+
let band-top = -band-height / 2 // puts the band center at dy 0
|
|
40
|
+
let band-center = band-top + band-height / 2
|
|
41
|
+
|
|
42
|
+
// The centered title/caption is placed content (not width-constrained), so a
|
|
43
|
+
// long caption would otherwise render on one line and run underneath the seal.
|
|
44
|
+
// Bound the caption to the width that stays clear of the seal on both sides
|
|
45
|
+
// and auto-shrink it to fit: the seal reaches `band-height - corner-overhang`
|
|
46
|
+
// into the text area, so reserving that plus a gutter on each side keeps the
|
|
47
|
+
// centered caption from touching the seal. `layout` resolves the text-area
|
|
48
|
+
// width so the bound tracks the page/margins.
|
|
49
|
+
let caption-gutter = 0.25in
|
|
50
|
+
let caption-clear = (band-height - corner-overhang) + caption-gutter
|
|
51
|
+
place(
|
|
52
|
+
dy: 0.625in - spacing.margin,
|
|
53
|
+
box(
|
|
54
|
+
width: 100%,
|
|
55
|
+
fill: none,
|
|
56
|
+
stroke: none,
|
|
57
|
+
layout(size => place(
|
|
58
|
+
center + top,
|
|
59
|
+
align(center)[
|
|
60
|
+
#set text(12pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")
|
|
61
|
+
#title\
|
|
62
|
+
#v(1pt)
|
|
63
|
+
#if not falsey(caption) {
|
|
64
|
+
fit-to-width(
|
|
65
|
+
size.width - 2 * caption-clear,
|
|
66
|
+
alignment: center,
|
|
67
|
+
box(text(10.5pt)[#caption]),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
)),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if letterhead-seal != none {
|
|
76
|
+
let seal-body = if falsey(letterhead-seal-subtitle) {
|
|
77
|
+
block[
|
|
78
|
+
#fit-box(width: corner-width, height: band-height)[#letterhead-seal]
|
|
79
|
+
]
|
|
80
|
+
} else {
|
|
81
|
+
// Isolate seal column from document `font_size`: stack `em` spacing and subtitle
|
|
82
|
+
// must not scale with body text (see frontmatter `set text(size: font_size)`).
|
|
83
|
+
// Subtitle is wrapped in `box` so it stays on one line and may extend past
|
|
84
|
+
// the seal's 2in column rather than wrapping.
|
|
85
|
+
block[
|
|
86
|
+
#set text(9pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")
|
|
87
|
+
// Spacing applies between positional stack children only, not one `[…]` body.
|
|
88
|
+
#stack(
|
|
89
|
+
spacing: 0.5em,
|
|
90
|
+
fit-box(width: corner-width, height: band-height)[#letterhead-seal],
|
|
91
|
+
box(upper(join-lines(letterhead-seal-subtitle))),
|
|
92
|
+
)
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
place(
|
|
96
|
+
left + top,
|
|
97
|
+
dx: -corner-overhang,
|
|
98
|
+
dy: band-top,
|
|
99
|
+
seal-body,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if letterhead-emblem != none {
|
|
104
|
+
// Mirror the seal: same overhang and width, centered on the seal's band
|
|
105
|
+
// axis. Placing the emblem's top at `band-center - height/2` keeps its
|
|
106
|
+
// center on `band-center` for any (possibly shorter) emblem height.
|
|
107
|
+
place(
|
|
108
|
+
right + top,
|
|
109
|
+
dx: corner-overhang,
|
|
110
|
+
dy: band-center - letterhead-emblem-height / 2,
|
|
111
|
+
block[
|
|
112
|
+
#fit-box(width: corner-width, height: letterhead-emblem-height, alignment: right + horizon)[#letterhead-emblem]
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// =============================================================================
|
|
119
|
+
// HEADER SECTIONS
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// AFH 33-337 "The Heading Section" specifies exact placement and format for:
|
|
122
|
+
// - Date: 1 inch from right edge, 1.75 inches from top
|
|
123
|
+
// - MEMORANDUM FOR: Second line below date
|
|
124
|
+
// - FROM: Second line below MEMORANDUM FOR
|
|
125
|
+
// - SUBJECT: Second line below FROM
|
|
126
|
+
|
|
127
|
+
// AFH 33-337 "Date": "Place the date 1 inch from the right edge, 1.75 inches from the top"
|
|
128
|
+
#let render-date-section(date, memo-style: "usaf") = {
|
|
129
|
+
align(right)[#display-date(date, memo-style: memo-style)]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// AFH 33-337 "MEMORANDUM FOR": "Place 'MEMORANDUM FOR' on the second line below the date"
|
|
133
|
+
#let render-for-section(recipients, cols) = {
|
|
134
|
+
blank-line()
|
|
135
|
+
grid(
|
|
136
|
+
columns: (auto, auto, 1fr),
|
|
137
|
+
"MEMORANDUM FOR",
|
|
138
|
+
" ",
|
|
139
|
+
align(left)[
|
|
140
|
+
#if type(recipients) == array {
|
|
141
|
+
create-auto-grid(recipients.map(upper), column-gutter: spacing.tab, cols: cols)
|
|
142
|
+
} else {
|
|
143
|
+
upper(recipients)
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// AFH 33-337 "FROM:": "Place 'FROM:' in uppercase, flush with the left margin,
|
|
150
|
+
// on the second line below the last line of the MEMORANDUM FOR element"
|
|
151
|
+
#let render-from-section(from-info) = {
|
|
152
|
+
blank-line()
|
|
153
|
+
from-info = join-lines(from-info)
|
|
154
|
+
|
|
155
|
+
grid(
|
|
156
|
+
columns: (auto, auto, 1fr),
|
|
157
|
+
"FROM:", " ", align(left)[#from-info],
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Whether a `references` entry carries no citation text.
|
|
162
|
+
///
|
|
163
|
+
/// Blank entries reach the template routinely: a template author leaves a
|
|
164
|
+
/// stub `- ` under `references:` for the user to fill in, and the quillmark
|
|
165
|
+
/// helper hands back `""` for an unset or whitespace-only markdown field.
|
|
166
|
+
/// Such an entry must not count as a reference — otherwise a lone blank one
|
|
167
|
+
/// satisfies the "exactly one reference" test below and renders as an empty
|
|
168
|
+
/// `()` after the subject.
|
|
169
|
+
///
|
|
170
|
+
/// - entry (any): A single `references` element
|
|
171
|
+
/// -> bool
|
|
172
|
+
#let blank-reference(entry) = falsey(entry) or entry == []
|
|
173
|
+
|
|
174
|
+
/// Drops `references` entries that carry no citation text and normalizes the
|
|
175
|
+
/// result to an array, so blank placeholder entries neither render on their
|
|
176
|
+
/// own nor affect the inline-vs-block decision.
|
|
177
|
+
///
|
|
178
|
+
/// - references (array | none): Raw reference entries
|
|
179
|
+
/// -> array
|
|
180
|
+
#let compact-references(references) = ensure-array(references).filter(entry => not blank-reference(entry))
|
|
181
|
+
|
|
182
|
+
// AFH 33-337 "SUBJECT:": "In all uppercase letters place 'SUBJECT:', flush with the
|
|
183
|
+
// left margin, on the second line below the last line of the FROM element"
|
|
184
|
+
#let render-subject-section(subject-text, inline-reference: none) = {
|
|
185
|
+
blank-line()
|
|
186
|
+
let content = if not blank-reference(inline-reference) {
|
|
187
|
+
// `subject-text` is a content field, so it is boxed for the same reason the
|
|
188
|
+
// inline reference is: a markup block's edge newlines would otherwise read
|
|
189
|
+
// as a space, doubling the one before the parenthesis.
|
|
190
|
+
[#box(subject-text) (#box(inline-reference))]
|
|
191
|
+
} else {
|
|
192
|
+
[#subject-text]
|
|
193
|
+
}
|
|
194
|
+
grid(
|
|
195
|
+
columns: (auto, auto, 1fr),
|
|
196
|
+
"SUBJECT:", " ", content,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// AFH 33-337: only render References block for two or more references.
|
|
201
|
+
// A single reference is rendered inline after the SUBJECT text instead.
|
|
202
|
+
#let render-references-section(references) = {
|
|
203
|
+
let references = compact-references(references)
|
|
204
|
+
if references.len() >= 2 {
|
|
205
|
+
blank-line()
|
|
206
|
+
grid(
|
|
207
|
+
columns: (auto, auto, 1fr),
|
|
208
|
+
// Each entry is markdown-converted content; spread them as enum items
|
|
209
|
+
// lettered "(a) (b) (c)" per AFH 33-337.
|
|
210
|
+
"References:", " ", enum(..references, numbering: "(a) ", body-indent: 0pt),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// =============================================================================
|
|
216
|
+
// SIGNATURE BLOCK
|
|
217
|
+
// =============================================================================
|
|
218
|
+
// AFH 33-337 "Signature Block": "Start the signature block on the fifth line below
|
|
219
|
+
// the last line of text and 4.5 inches from the left edge of the page or three
|
|
220
|
+
// spaces to the right of page center"
|
|
221
|
+
// AFH 33-337 "Do not place the signature element on a continuation page by itself"
|
|
222
|
+
// AFH 33-337 long-name example: "Signature block adjusted to the left" when a
|
|
223
|
+
// long name would otherwise exceed the right margin.
|
|
224
|
+
|
|
225
|
+
#let render-signature-block(signature-lines, signature-blank-lines: 4, signing-field: none) = {
|
|
226
|
+
signature-lines = ensure-array(signature-lines)
|
|
227
|
+
// AFH 33-337: "fifth line below" = 4 blank lines between text and signature block.
|
|
228
|
+
// breakable: false discourages orphaning the signature block onto a page by itself.
|
|
229
|
+
blank-lines(signature-blank-lines)
|
|
230
|
+
// AFH 33-337 allows two equivalent anchors: 4.5in from the left edge, or three
|
|
231
|
+
// spaces right of page center. On 8.5in stock these coincide (page center =
|
|
232
|
+
// 4.25in; three TNR-12pt spaces ≈ 0.25in), so we use 4.5in as the canonical
|
|
233
|
+
// anchor. pad() is relative to the text area, hence (4.5in - margin).
|
|
234
|
+
let default-pad = 4.5in - spacing.margin
|
|
235
|
+
context {
|
|
236
|
+
// Measure each line at its rendered settings to detect long-name overflow.
|
|
237
|
+
let body-width = page.width - 2 * spacing.margin
|
|
238
|
+
let widest = 0pt
|
|
239
|
+
for line in signature-lines {
|
|
240
|
+
let w = measure(text(hyphenate: false, line)).width
|
|
241
|
+
if w > widest { widest = w }
|
|
242
|
+
}
|
|
243
|
+
// If the widest line would overflow the right margin at the standard
|
|
244
|
+
// anchor, shift the block left just enough to fit. Clamp at 0 so the
|
|
245
|
+
// block never crosses the left margin.
|
|
246
|
+
let available = body-width - default-pad
|
|
247
|
+
let left-pad = if widest > available {
|
|
248
|
+
let shifted = body-width - widest
|
|
249
|
+
if shifted < 0pt { 0pt } else { shifted }
|
|
250
|
+
} else {
|
|
251
|
+
default-pad
|
|
252
|
+
}
|
|
253
|
+
block(breakable: false)[
|
|
254
|
+
#if signing-field != none {
|
|
255
|
+
let stride = {
|
|
256
|
+
let s = LINE_STRIDE.get()
|
|
257
|
+
if s == none {
|
|
258
|
+
let one-line = measure(par(spacing: 0pt)[x]).height
|
|
259
|
+
measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
|
|
260
|
+
} else { s }
|
|
261
|
+
}
|
|
262
|
+
place(
|
|
263
|
+
dx: left-pad,
|
|
264
|
+
dy: -(stride * signature-blank-lines),
|
|
265
|
+
box(width: body-width - left-pad, height: stride * signature-blank-lines, signing-field),
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
#align(left)[
|
|
269
|
+
#pad(left: left-pad)[
|
|
270
|
+
#text(hyphenate: false)[
|
|
271
|
+
#for line in signature-lines {
|
|
272
|
+
// AFH 33-337: "indent the next line to begin under the third character
|
|
273
|
+
// of the line above" — 2-character indent ≈ 1em in Times New Roman 12pt
|
|
274
|
+
par(hanging-indent: .5em, line)
|
|
275
|
+
}
|
|
276
|
+
]
|
|
277
|
+
]
|
|
278
|
+
]
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// =============================================================================
|
|
284
|
+
// ACTION LINE RENDERING
|
|
285
|
+
// =============================================================================
|
|
286
|
+
// Renders the decision line for indorsement memos — an "either / or" pair
|
|
287
|
+
// where the endorser's choice is circled and the rejected option struck out.
|
|
288
|
+
//
|
|
289
|
+
// Two option pairs are supported, reflecting the two roles an indorsement
|
|
290
|
+
// plays in a coordination chain: coordinating officials Concur / Nonconcur,
|
|
291
|
+
// while the final approval authority Approves / Disapproves.
|
|
292
|
+
//
|
|
293
|
+
// Each pair also has an "undecided" form that renders both options plain, for
|
|
294
|
+
// printing a memo the endorser circles by hand when signing.
|
|
295
|
+
//
|
|
296
|
+
// Empty/none suppression is handled by the caller before this is invoked.
|
|
297
|
+
|
|
298
|
+
// Maps each `action` value to the option pair it renders and the index of the
|
|
299
|
+
// selected option within that pair (`none` selects neither).
|
|
300
|
+
#let ACTION_FORMS = (
|
|
301
|
+
"undecided": (("Approve", "Disapprove"), none),
|
|
302
|
+
"approve": (("Approve", "Disapprove"), 0),
|
|
303
|
+
"disapprove": (("Approve", "Disapprove"), 1),
|
|
304
|
+
"undecided_concur": (("Concur", "Nonconcur"), none),
|
|
305
|
+
"concur": (("Concur", "Nonconcur"), 0),
|
|
306
|
+
"nonconcur": (("Concur", "Nonconcur"), 1),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
#let render-action-line(action, trailing-blank-line: true) = {
|
|
310
|
+
assert(
|
|
311
|
+
action in ACTION_FORMS,
|
|
312
|
+
message: "action must be one of " + ACTION_FORMS.keys().map(k => "\"" + k + "\"").join(", "),
|
|
313
|
+
)
|
|
314
|
+
let (options, selected) = ACTION_FORMS.at(action)
|
|
315
|
+
// Circle the selected option using a box with rounded corners; strike the
|
|
316
|
+
// one it was chosen over. When neither is selected both render plain.
|
|
317
|
+
// Use baseline parameter to maintain vertical text alignment.
|
|
318
|
+
let render-option(index) = {
|
|
319
|
+
let option = options.at(index)
|
|
320
|
+
if selected == none {
|
|
321
|
+
option
|
|
322
|
+
} else if selected == index {
|
|
323
|
+
box(stroke: 0.5pt + black, radius: 2pt, inset: 2pt, baseline: 2pt)[#option]
|
|
324
|
+
} else {
|
|
325
|
+
strike(option)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// No leading blank-line: the caller (indorsement.typ) already emits the
|
|
329
|
+
// header→content gap once. The action line's `block(sticky: true)`
|
|
330
|
+
// additionally inherits `block.above: spacing.line` so the visual gap
|
|
331
|
+
// above matches the gap above a body's first paragraph.
|
|
332
|
+
//
|
|
333
|
+
// Keep the action line with the following content (body or signature block)
|
|
334
|
+
// using the same sticky-block pattern that body.typ applies to the last
|
|
335
|
+
// paragraph, per AFH 33-337 §11 orphan-prevention rules.
|
|
336
|
+
block(sticky: true)[#render-option(0) / #render-option(1)]
|
|
337
|
+
// Trailing blank-line places the body's first paragraph one line below
|
|
338
|
+
// the action, mirroring the gap above it. Suppressed when the body is
|
|
339
|
+
// empty so the signature block's own 4-line gap lands on AFH 33-337's
|
|
340
|
+
// "fifth line below the last line of text" anchor.
|
|
341
|
+
if trailing-blank-line {
|
|
342
|
+
blank-line()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// =============================================================================
|
|
347
|
+
// TABLE RENDERING
|
|
348
|
+
// =============================================================================
|
|
349
|
+
// AFH 33-337 does not specify table formatting, so we follow the general
|
|
350
|
+
// aesthetic principles of the standard: plain black borders, no decorative
|
|
351
|
+
// fills, and the body font inherited throughout.
|
|
352
|
+
|
|
353
|
+
/// Renders a table with USAF memorandum–consistent formatting.
|
|
354
|
+
///
|
|
355
|
+
/// Applies simple 0.5pt black cell borders and standard padding to any
|
|
356
|
+
/// Typst `table` element, keeping the visual style clean and formal.
|
|
357
|
+
/// Font and size are inherited from the surrounding body text.
|
|
358
|
+
///
|
|
359
|
+
/// - it (content): The table element to style and render
|
|
360
|
+
/// -> content
|
|
361
|
+
#let render-memo-table(it) = {
|
|
362
|
+
// AFH 33-337 does not specify table formatting, so we follow the general
|
|
363
|
+
// aesthetic principles of the standard: bold headers for clarity.
|
|
364
|
+
show table.cell.where(y: 0): set text(weight: "bold")
|
|
365
|
+
set table(
|
|
366
|
+
stroke: 0.5pt + black,
|
|
367
|
+
inset: (x: 0.5em, y: 0.4em),
|
|
368
|
+
)
|
|
369
|
+
it
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// =============================================================================
|
|
373
|
+
// BACKMATTER SECTIONS
|
|
374
|
+
// =============================================================================
|
|
375
|
+
// AFH 33-337 "Attachment or Attachments": "Place 'Attachment:' (for a single attachment)
|
|
376
|
+
// or '# Attachments:' (for two or more attachments) at the left margin, on the third
|
|
377
|
+
// line below the signature element"
|
|
378
|
+
// AFH 33-337 "Courtesy Copy Element": "place 'cc:' flush with the left margin, on the
|
|
379
|
+
// second line below the attachment element"
|
|
380
|
+
|
|
381
|
+
#let render-backmatter-section(
|
|
382
|
+
content,
|
|
383
|
+
section-label,
|
|
384
|
+
numbering-style: none,
|
|
385
|
+
continuation-label: none,
|
|
386
|
+
) = {
|
|
387
|
+
let formatted-content = {
|
|
388
|
+
// Use text() wrapper to prevent section label from being treated as a paragraph
|
|
389
|
+
text()[#section-label]
|
|
390
|
+
linebreak()
|
|
391
|
+
if numbering-style != none {
|
|
392
|
+
let items = ensure-array(content)
|
|
393
|
+
enum(..items, numbering: numbering-style)
|
|
394
|
+
} else {
|
|
395
|
+
join-lines(content)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
context {
|
|
400
|
+
let available-space = page.height - here().position().y - 1in
|
|
401
|
+
if measure(formatted-content).height > available-space {
|
|
402
|
+
// Attachments pass continuation-label ("… (listed on next page):" per AFH 33-337).
|
|
403
|
+
// cc: and DISTRIBUTION: use a neutral default — "listed" applies to attachment lists only.
|
|
404
|
+
let continuation-text = if continuation-label != none {
|
|
405
|
+
text()[#continuation-label]
|
|
406
|
+
} else {
|
|
407
|
+
text()[#(section-label + " (continued on next page)")]
|
|
408
|
+
}
|
|
409
|
+
continuation-text
|
|
410
|
+
pagebreak()
|
|
411
|
+
}
|
|
412
|
+
formatted-content
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#let calculate-backmatter-spacing(is-first-section) = {
|
|
417
|
+
context {
|
|
418
|
+
let line_count = if is-first-section { 2 } else { 1 }
|
|
419
|
+
blank-lines(line_count)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#let render-backmatter-sections(
|
|
424
|
+
attachments: none,
|
|
425
|
+
cc: none,
|
|
426
|
+
distribution: none,
|
|
427
|
+
leading-pagebreak: false,
|
|
428
|
+
) = {
|
|
429
|
+
let has-backmatter = (
|
|
430
|
+
(attachments != none and attachments.len() > 0)
|
|
431
|
+
or (cc != none and cc.len() > 0)
|
|
432
|
+
or (distribution != none and distribution.len() > 0)
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if leading-pagebreak and has-backmatter {
|
|
436
|
+
pagebreak(weak: true)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if attachments != none and attachments.len() > 0 {
|
|
440
|
+
calculate-backmatter-spacing(true)
|
|
441
|
+
let attachment-count = attachments.len()
|
|
442
|
+
let section-label = if attachment-count == 1 { "Attachment:" } else { str(attachment-count) + " Attachments:" }
|
|
443
|
+
let continuation-label = (
|
|
444
|
+
(if attachment-count == 1 { "Attachment" } else { str(attachment-count) + " Attachments" })
|
|
445
|
+
+ " (listed on next page):"
|
|
446
|
+
)
|
|
447
|
+
// AFH 33-337: a single attachment is not numbered; numbering applies to two or more.
|
|
448
|
+
let numbering-style = if attachment-count == 1 { none } else { "1." }
|
|
449
|
+
render-backmatter-section(attachments, section-label, numbering-style: numbering-style, continuation-label: continuation-label)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if cc != none and cc.len() > 0 {
|
|
453
|
+
calculate-backmatter-spacing(attachments == none or attachments.len() == 0)
|
|
454
|
+
render-backmatter-section(cc, "cc:")
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if distribution != none and distribution.len() > 0 {
|
|
458
|
+
calculate-backmatter-spacing((attachments == none or attachments.len() == 0) and (cc == none or cc.len() == 0))
|
|
459
|
+
render-backmatter-section(distribution, "DISTRIBUTION:")
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|