@airmark/quiver 0.28.0 → 0.29.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 +321 -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 +120 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// body.typ: Paragraph body rendering for USAF memorandum sections
|
|
2
|
+
//
|
|
3
|
+
// This module implements the visual rendering of AFH 33-337 compliant
|
|
4
|
+
// paragraph bodies with proper numbering, nesting, and formatting.
|
|
5
|
+
|
|
6
|
+
#import "config.typ": *
|
|
7
|
+
#import "utils.typ": *
|
|
8
|
+
#import "primitives.typ": render-memo-table
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// PARAGRAPH NUMBERING UTILITIES
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
/// Gets the numbering format for a specific paragraph level.
|
|
15
|
+
///
|
|
16
|
+
/// - level (int): Paragraph nesting level (0-based)
|
|
17
|
+
/// -> str | function
|
|
18
|
+
#let get-paragraph-numbering-format(level) = {
|
|
19
|
+
paragraph-config.numbering-formats.at(level, default: "i.")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Calculates indentation for USAF-style paragraphs from explicit counter values.
|
|
23
|
+
///
|
|
24
|
+
/// - level (int): Paragraph nesting level (0-based)
|
|
25
|
+
/// - level-counts (dictionary): Maps level index strings to their current counter values
|
|
26
|
+
/// -> length
|
|
27
|
+
#let calculate-indent-from-counts(level, level-counts) = {
|
|
28
|
+
if level == 0 {
|
|
29
|
+
return 0pt
|
|
30
|
+
}
|
|
31
|
+
let total-indent = 0pt
|
|
32
|
+
for ancestor-level in range(level) {
|
|
33
|
+
let ancestor-value = level-counts.at(str(ancestor-level), default: 1)
|
|
34
|
+
let ancestor-format = get-paragraph-numbering-format(ancestor-level)
|
|
35
|
+
let ancestor-number = numbering(ancestor-format, ancestor-value)
|
|
36
|
+
total-indent += measure([#ancestor-number#" "]).width
|
|
37
|
+
}
|
|
38
|
+
total-indent
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Calculates fixed indentation for DAF paragraph levels.
|
|
42
|
+
///
|
|
43
|
+
/// First nested level starts at `nested-first-level-indent` (1in); deeper levels
|
|
44
|
+
/// add `nested-step` (0.5in) per additional depth.
|
|
45
|
+
///
|
|
46
|
+
/// - level (int): Paragraph nesting level (0-based)
|
|
47
|
+
/// -> length
|
|
48
|
+
#let calculate-daf-indent(level) = {
|
|
49
|
+
if level <= 0 {
|
|
50
|
+
return 0pt
|
|
51
|
+
}
|
|
52
|
+
daf-paragraph.nested-first-level-indent + (level - 1) * daf-paragraph.nested-step
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Resets counter entries from `start` upward to 1 in the level-counts dictionary.
|
|
56
|
+
#let reset-levels-from(level-counts, start, max-levels) = {
|
|
57
|
+
for child in range(start, max-levels) {
|
|
58
|
+
level-counts.insert(str(child), 1)
|
|
59
|
+
}
|
|
60
|
+
level-counts
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Formats a paragraph (or continuation) with a given indent strategy.
|
|
64
|
+
///
|
|
65
|
+
/// - body (content): Paragraph content
|
|
66
|
+
/// - level (int): Nesting level (0-based)
|
|
67
|
+
/// - level-counts (dictionary): Current counter values per level
|
|
68
|
+
/// - indent-fn (function): `(level, level-counts) -> length`
|
|
69
|
+
/// - continuation (bool): If true, adds number-label width to alignment
|
|
70
|
+
/// -> content
|
|
71
|
+
#let format-par(body, level, level-counts, indent-fn, continuation: false) = {
|
|
72
|
+
let indent-width = indent-fn(level, level-counts)
|
|
73
|
+
if continuation {
|
|
74
|
+
let current-value = level-counts.at(str(level), default: 1)
|
|
75
|
+
let number-text = numbering(get-paragraph-numbering-format(level), current-value)
|
|
76
|
+
[#h(indent-width + measure([#number-text#" "]).width)#body]
|
|
77
|
+
} else {
|
|
78
|
+
let current-value = level-counts.at(str(level), default: 1)
|
|
79
|
+
let number-text = numbering(get-paragraph-numbering-format(level), current-value)
|
|
80
|
+
[#h(indent-width)#number-text#" "#body]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// PARAGRAPH BODY RENDERING
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// AFH 33-337 "The Text of the Official Memorandum" §1-12 specifies:
|
|
88
|
+
// - Single-space text, double-space between paragraphs
|
|
89
|
+
// - Number and letter each paragraph and subparagraph
|
|
90
|
+
// - "A single paragraph is not numbered" (§2)
|
|
91
|
+
// - First paragraph flush left, never indented
|
|
92
|
+
// - Indent sub-paragraphs to align with first character of parent paragraph text
|
|
93
|
+
#let render-body(content, memo-style: "usaf") = {
|
|
94
|
+
let PAR_BUFFER = state("PAR_BUFFER")
|
|
95
|
+
PAR_BUFFER.update(())
|
|
96
|
+
let NEST_DOWN = counter("NEST_DOWN")
|
|
97
|
+
NEST_DOWN.update(0)
|
|
98
|
+
let NEST_UP = counter("NEST_UP")
|
|
99
|
+
NEST_UP.update(0)
|
|
100
|
+
let IS_HEADING = state("IS_HEADING")
|
|
101
|
+
IS_HEADING.update(false)
|
|
102
|
+
// Tracks whether the next paragraph is the first block in a list item.
|
|
103
|
+
// When true, the next `show par` captures a numbered item; subsequent
|
|
104
|
+
// paragraphs within the same item are continuations (no new number).
|
|
105
|
+
let ITEM_FIRST_PAR = state("ITEM_FIRST_PAR")
|
|
106
|
+
ITEM_FIRST_PAR.update(false)
|
|
107
|
+
|
|
108
|
+
// The first pass parses paragraphs, list items, etc. into standardized arrays
|
|
109
|
+
let first_pass = {
|
|
110
|
+
// Collect pars with nesting level
|
|
111
|
+
show par: p => context {
|
|
112
|
+
let nest_level = NEST_DOWN.get().at(0) - NEST_UP.get().at(0)
|
|
113
|
+
let is_heading = IS_HEADING.get()
|
|
114
|
+
let is_first_par = ITEM_FIRST_PAR.get()
|
|
115
|
+
|
|
116
|
+
// Determine if this is a continuation block within a multi-block list item.
|
|
117
|
+
// A continuation is a non-first paragraph inside a list item (nest_level > 0).
|
|
118
|
+
let is_continuation = nest_level > 0 and not is_first_par
|
|
119
|
+
|
|
120
|
+
PAR_BUFFER.update(pars => {
|
|
121
|
+
pars.push((
|
|
122
|
+
content: text([#p.body]),
|
|
123
|
+
nest_level: nest_level,
|
|
124
|
+
kind: if is_heading { "heading" } else if is_continuation { "continuation" } else { "par" },
|
|
125
|
+
))
|
|
126
|
+
pars
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// After the first paragraph of a list item, mark subsequent ones as continuations
|
|
130
|
+
if nest_level > 0 and is_first_par {
|
|
131
|
+
ITEM_FIRST_PAR.update(false)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
p
|
|
135
|
+
}
|
|
136
|
+
// Collect tables — captured as-is without paragraph numbering
|
|
137
|
+
show table: t => context {
|
|
138
|
+
PAR_BUFFER.update(pars => {
|
|
139
|
+
pars.push((
|
|
140
|
+
content: t,
|
|
141
|
+
nest_level: -1,
|
|
142
|
+
kind: "table",
|
|
143
|
+
))
|
|
144
|
+
pars
|
|
145
|
+
})
|
|
146
|
+
t
|
|
147
|
+
}
|
|
148
|
+
{
|
|
149
|
+
show heading: h => {
|
|
150
|
+
IS_HEADING.update(true)
|
|
151
|
+
[#parbreak()#h.body#parbreak()]
|
|
152
|
+
IS_HEADING.update(false)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Convert list/enum items to pars
|
|
156
|
+
// Note: No context wrapper here - state updates don't need it and cause
|
|
157
|
+
// layout convergence issues with many list items
|
|
158
|
+
show enum.item: it => {
|
|
159
|
+
NEST_DOWN.step()
|
|
160
|
+
ITEM_FIRST_PAR.update(true)
|
|
161
|
+
[#parbreak()#it.body#parbreak()]
|
|
162
|
+
NEST_UP.step()
|
|
163
|
+
}
|
|
164
|
+
show list.item: it => {
|
|
165
|
+
NEST_DOWN.step()
|
|
166
|
+
ITEM_FIRST_PAR.update(true)
|
|
167
|
+
[#parbreak()#it.body#parbreak()]
|
|
168
|
+
NEST_UP.step()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
{
|
|
172
|
+
// Typst bug bandaid:
|
|
173
|
+
// `show par` will not collect wrappers unless there is content outside
|
|
174
|
+
// Add zero width space to always have content outside of wrapper
|
|
175
|
+
show strong: it => {
|
|
176
|
+
[#it#sym.zws]
|
|
177
|
+
}
|
|
178
|
+
show emph: it => {
|
|
179
|
+
[#it#sym.zws]
|
|
180
|
+
}
|
|
181
|
+
show underline: it => {
|
|
182
|
+
[#it#sym.zws]
|
|
183
|
+
}
|
|
184
|
+
show raw: it => {
|
|
185
|
+
[#it#sym.zws]
|
|
186
|
+
}
|
|
187
|
+
[#content#parbreak()]
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Use place() to prevent hidden content from affecting layout flow
|
|
192
|
+
place(hide(first_pass))
|
|
193
|
+
|
|
194
|
+
// Second pass: consume par buffer
|
|
195
|
+
//
|
|
196
|
+
// PAR_BUFFER item dictionary layout:
|
|
197
|
+
// item.content — the paragraph body or table element
|
|
198
|
+
// item.nest_level — nesting depth (−1 for tables)
|
|
199
|
+
// item.kind — "par", "heading", "table", or "continuation"
|
|
200
|
+
context {
|
|
201
|
+
let heading_buffer = none
|
|
202
|
+
let heading_level = 0
|
|
203
|
+
// Filter out zero-width paragraphs so that an empty body (e.g. empty
|
|
204
|
+
// string from a KIND with no text) emits nothing and collapses to zero
|
|
205
|
+
// vertical space. Tables are always kept regardless of measured width.
|
|
206
|
+
let items = PAR_BUFFER.get().filter(item =>
|
|
207
|
+
item.kind == "table" or measure(item.content).width > 0pt
|
|
208
|
+
)
|
|
209
|
+
if items.len() == 0 { return }
|
|
210
|
+
// Only top-level paragraphs count for AFH 33-337 §2 numbering purposes
|
|
211
|
+
let par_count = items.filter(item => item.kind == "par").len()
|
|
212
|
+
let total_count = items.len()
|
|
213
|
+
|
|
214
|
+
// Track paragraph numbers per level manually to avoid nested-context
|
|
215
|
+
// counter propagation issues. Dictionary maps level index (as string)
|
|
216
|
+
// to the current counter value at that level.
|
|
217
|
+
let max-levels = paragraph-config.numbering-formats.len()
|
|
218
|
+
let level-counts = (:)
|
|
219
|
+
for lvl in range(max-levels) {
|
|
220
|
+
level-counts.insert(str(lvl), 1)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let i = 0
|
|
224
|
+
let any_emitted = false
|
|
225
|
+
for item in items {
|
|
226
|
+
i += 1
|
|
227
|
+
let kind = item.kind
|
|
228
|
+
let item_content = item.content
|
|
229
|
+
|
|
230
|
+
// A buffered heading runs into this element only when the two belong to
|
|
231
|
+
// the same item: a later block of this list item ("continuation"), or,
|
|
232
|
+
// at top level, the next paragraph. The first block of the *next* item,
|
|
233
|
+
// a table (nest_level −1, so the level test alone excludes it), and
|
|
234
|
+
// another heading all fail the test, and each would otherwise carry the
|
|
235
|
+
// heading's text somewhere it was not authored. Those emit the heading
|
|
236
|
+
// on its own line, the treatment a heading before a table takes.
|
|
237
|
+
if heading_buffer != none {
|
|
238
|
+
let runs_in = (
|
|
239
|
+
kind != "heading"
|
|
240
|
+
and item.nest_level == heading_level
|
|
241
|
+
and (heading_level == 0 or kind == "continuation")
|
|
242
|
+
)
|
|
243
|
+
if runs_in {
|
|
244
|
+
item_content = [#strong[#heading_buffer.] #item_content]
|
|
245
|
+
} else {
|
|
246
|
+
if any_emitted { blank-line() }
|
|
247
|
+
strong[#heading_buffer.]
|
|
248
|
+
any_emitted = true
|
|
249
|
+
}
|
|
250
|
+
heading_buffer = none
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Buffer this heading for the next element to run into. The level it was
|
|
254
|
+
// captured at rides along: the run-in test needs it, and the buffer is
|
|
255
|
+
// read one iteration later, by which time `item` is the *next* element.
|
|
256
|
+
if kind == "heading" {
|
|
257
|
+
heading_buffer = item_content
|
|
258
|
+
heading_level = item.nest_level
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Format based on element kind
|
|
263
|
+
let nest_level = item.nest_level
|
|
264
|
+
let indent-fn = if memo-style == "daf" {
|
|
265
|
+
(level, _counts) => calculate-daf-indent(level)
|
|
266
|
+
} else {
|
|
267
|
+
(level, counts) => calculate-indent-from-counts(level, counts)
|
|
268
|
+
}
|
|
269
|
+
let final_par = {
|
|
270
|
+
if kind == "table" {
|
|
271
|
+
render-memo-table(item_content)
|
|
272
|
+
} else if kind == "continuation" {
|
|
273
|
+
// Continuation block within a multi-block list item:
|
|
274
|
+
// indent to align with preceding numbered paragraph's text, no new number.
|
|
275
|
+
// level-counts still holds the value of the preceding numbered paragraph.
|
|
276
|
+
if memo-style == "daf" and nest_level == 0 {
|
|
277
|
+
item_content
|
|
278
|
+
} else {
|
|
279
|
+
format-par(item_content, nest_level, level-counts, indent-fn, continuation: true)
|
|
280
|
+
}
|
|
281
|
+
} else if memo-style == "daf" {
|
|
282
|
+
if nest_level > 0 {
|
|
283
|
+
let par = format-par(item_content, nest_level, level-counts, indent-fn)
|
|
284
|
+
level-counts.insert(str(nest_level), level-counts.at(str(nest_level), default: 1) + 1)
|
|
285
|
+
level-counts = reset-levels-from(level-counts, nest_level + 1, max-levels)
|
|
286
|
+
par
|
|
287
|
+
} else {
|
|
288
|
+
// DAF top-level paragraphs are unnumbered and first-line indented.
|
|
289
|
+
// Reset nested counters so each new top-level paragraph restarts children.
|
|
290
|
+
level-counts = reset-levels-from(level-counts, 0, max-levels)
|
|
291
|
+
[#h(daf-paragraph.top-first-line-indent)#item_content]
|
|
292
|
+
}
|
|
293
|
+
} else if par_count > 1 {
|
|
294
|
+
// Apply paragraph numbering per AFH 33-337 §2
|
|
295
|
+
let par = format-par(item_content, nest_level, level-counts, indent-fn)
|
|
296
|
+
level-counts.insert(str(nest_level), level-counts.at(str(nest_level)) + 1)
|
|
297
|
+
level-counts = reset-levels-from(level-counts, nest_level + 1, max-levels)
|
|
298
|
+
par
|
|
299
|
+
} else {
|
|
300
|
+
// AFH 33-337 §2: "A single paragraph is not numbered"
|
|
301
|
+
item_content
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Blank line between paragraphs. The header→body gap (i.e. before
|
|
306
|
+
// the first emitted paragraph) is the caller's responsibility —
|
|
307
|
+
// emitting it here would put the v() inside a nested
|
|
308
|
+
// `context { for … }` block, where it does not combine with the
|
|
309
|
+
// preceding header section's block-spacing the same way as a
|
|
310
|
+
// top-level blank-line() call.
|
|
311
|
+
// AFH 33-337 §11 rule applied below: "Avoid dividing a paragraph of
|
|
312
|
+
// less than four lines between two pages"
|
|
313
|
+
if any_emitted { blank-line() }
|
|
314
|
+
any_emitted = true
|
|
315
|
+
if i == total_count {
|
|
316
|
+
let available_width = page.width - spacing.margin * 2
|
|
317
|
+
|
|
318
|
+
// Use the shared measured line stride used by blank-line spacing.
|
|
319
|
+
let line_height = {
|
|
320
|
+
let cached = LINE_STRIDE.get()
|
|
321
|
+
if cached != none {
|
|
322
|
+
cached
|
|
323
|
+
} else {
|
|
324
|
+
let one-line = measure(par(spacing: 0pt)[x]).height
|
|
325
|
+
measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Calculate last item's height
|
|
329
|
+
let par_height = measure(final_par, width: available_width).height
|
|
330
|
+
|
|
331
|
+
let estimated_lines = calc.ceil(par_height / line_height)
|
|
332
|
+
|
|
333
|
+
if estimated_lines < 4 {
|
|
334
|
+
// Short content (< 4 lines): make sticky to keep with signature
|
|
335
|
+
block(sticky: true)[#final_par]
|
|
336
|
+
} else {
|
|
337
|
+
// Longer content (≥ 4 lines): use default breaking behavior
|
|
338
|
+
block(breakable: true)[#final_par]
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
// Wrap every non-last emission in a plain block so the document-wide
|
|
342
|
+
// `set block(above: spacing.line)` rule contributes the same 0.5em
|
|
343
|
+
// gap above every paragraph, including the first (matching the
|
|
344
|
+
// single-paragraph case which always passes through the
|
|
345
|
+
// `i == total_count` branch) and middle paragraphs (so the gap
|
|
346
|
+
// between the 1st and 2nd doesn't shrink relative to the gap
|
|
347
|
+
// between the 2nd-to-last and last). Bare emission would skip
|
|
348
|
+
// `block.above` entirely and visibly compress the spacing.
|
|
349
|
+
block[#final_par]
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A heading with nothing after it never ran into anything, and the buffer
|
|
354
|
+
// dies with the loop unless it is drained here. Sticky for the same reason
|
|
355
|
+
// the last item is: a standalone heading is one line, so it keeps with the
|
|
356
|
+
// signature block rather than opening a page alone.
|
|
357
|
+
if heading_buffer != none {
|
|
358
|
+
if any_emitted { blank-line() }
|
|
359
|
+
block(sticky: true)[#strong[#heading_buffer.]]
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// config.typ: Configuration constants and defaults for USAF memorandum template
|
|
2
|
+
//
|
|
3
|
+
// This module defines core configuration values that implement AFH 33-337 Chapter 14
|
|
4
|
+
// formatting requirements for official USAF memorandums.
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// SPACING CONSTANTS
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// AFH 33-337 specifies precise spacing requirements throughout Chapter 14
|
|
10
|
+
|
|
11
|
+
#let spacing = (
|
|
12
|
+
line: .5em, // Internal line spacing for readability (`par.leading`; gap between line boxes)
|
|
13
|
+
tab: 0.5in, // Tab stop for multi-column recipient alignment
|
|
14
|
+
margin: 1in, // AFH 33-337 §4: "Use 1-inch margins on the left, right and bottom"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// TYPOGRAPHY DEFAULTS
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// AFH 33-337 §5: "Use 12 point Times New Roman font for text"
|
|
21
|
+
|
|
22
|
+
#let DEFAULT_LETTERHEAD_FONTS = ("Copperplate CC", "NimbusRomNo9L")
|
|
23
|
+
#let DEFAULT_BODY_FONTS = ("NimbusRomNo9L",) // AFH 33-337 §5: Times New Roman required; NimbusRomNo9L is a metric-compatible clone
|
|
24
|
+
// Static monospace face for raw/code text. Liberation Mono is metric-compatible with
|
|
25
|
+
// Courier New; only the regular weight is bundled (no bold or italic variants).
|
|
26
|
+
#let DEFAULT_MONO_FONTS = ("Liberation Mono",)
|
|
27
|
+
#let LETTERHEAD_COLOR = rgb("#355e93") // Faded USAF blue for letterhead
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// PARAGRAPH CONFIGURATION
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// AFH 33-337 "The Text of the Official Memorandum" §2:
|
|
33
|
+
// "Number and letter each paragraph and subparagraph"
|
|
34
|
+
// Hierarchical numbering: 1., a., (1), (a), etc.
|
|
35
|
+
|
|
36
|
+
#let paragraph-config = (
|
|
37
|
+
counter-prefix: "par-counter-",
|
|
38
|
+
// AFH 33-337 §2: Hierarchical paragraph numbering format
|
|
39
|
+
// Level 0: 1., 2., 3. | Level 1: a., b., c. | Level 2: (1), (2), (3) | Level 3: (a), (b), (c)
|
|
40
|
+
numbering-formats: ("1.", "a.", "(1)", "(a)", n => underline(str(n)), n => underline(str(n))),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// DAF (Headquarters) memo body: first-line indent for unnumbered paragraphs; nested
|
|
44
|
+
// items start at 1in, then +0.5in per additional nesting depth.
|
|
45
|
+
#let daf-paragraph = (
|
|
46
|
+
top-first-line-indent: 0.5in,
|
|
47
|
+
nested-first-level-indent: 1in,
|
|
48
|
+
nested-step: 0.5in,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// =============================================================================
|
|
52
|
+
// COUNTERS
|
|
53
|
+
// =============================================================================
|
|
54
|
+
|
|
55
|
+
#let counters = (
|
|
56
|
+
indorsement: counter("indorsement"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// CLASSIFICATION COLORS
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// AFH 33-337 §3: "Follow AFI 31-401, Information Security Program Management,
|
|
63
|
+
// applicable executive orders and DoD guidance for the necessary markings on
|
|
64
|
+
// classified correspondence."
|
|
65
|
+
// Color values follow DoD/CAPCO standard classification marking colors:
|
|
66
|
+
// - TOP SECRET: orange (#FF671F)
|
|
67
|
+
// - SECRET: red (#C8102E)
|
|
68
|
+
// - CONFIDENTIAL: black (per project style guide)
|
|
69
|
+
// - CUI: black (DoDM 5200.48; per project style guide)
|
|
70
|
+
// - UNCLASSIFIED: green (#007A33)
|
|
71
|
+
|
|
72
|
+
#let CLASSIFICATION_COLORS = (
|
|
73
|
+
"TOP SECRET": rgb(255, 103, 31),
|
|
74
|
+
"SECRET": rgb(200, 16, 46),
|
|
75
|
+
"CONFIDENTIAL": rgb(0, 0, 0),
|
|
76
|
+
"CUI": rgb(0, 0, 0),
|
|
77
|
+
"UNCLASSIFIED": rgb(0, 122, 51),
|
|
78
|
+
)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// frontmatter.typ: Frontmatter show rule for USAF memorandum
|
|
2
|
+
//
|
|
3
|
+
// This module implements the frontmatter (heading section) of a USAF memorandum
|
|
4
|
+
// per AFH 33-337 Chapter 14 "The Heading Section". It handles:
|
|
5
|
+
// - Page setup with proper margins
|
|
6
|
+
// - Letterhead rendering
|
|
7
|
+
// - Date, MEMORANDUM FOR, FROM, SUBJECT, and References placement
|
|
8
|
+
// - Classification markings in headers/footers
|
|
9
|
+
|
|
10
|
+
#import "primitives.typ": *
|
|
11
|
+
|
|
12
|
+
#let frontmatter(
|
|
13
|
+
subject: none,
|
|
14
|
+
memo_for: none,
|
|
15
|
+
memo_from: none,
|
|
16
|
+
date: none,
|
|
17
|
+
references: none,
|
|
18
|
+
letterhead_title: "DEPARTMENT OF THE AIR FORCE",
|
|
19
|
+
letterhead_caption: "[YOUR SQUADRON/UNIT NAME]",
|
|
20
|
+
letterhead_seal: none,
|
|
21
|
+
letterhead_seal_subtitle: none, // optional line under seal (9pt bold caps); ignored if no seal
|
|
22
|
+
letterhead_emblem: none, // optional image placed opposite the seal (right side)
|
|
23
|
+
letterhead_emblem_height: 1in, // emblem fit-box height; reduce for shorter emblems
|
|
24
|
+
letterhead_font: DEFAULT_LETTERHEAD_FONTS,
|
|
25
|
+
body_font: DEFAULT_BODY_FONTS,
|
|
26
|
+
font_size: 12pt,
|
|
27
|
+
memo_for_cols: 3,
|
|
28
|
+
classification_level: none,
|
|
29
|
+
dissemination: none,
|
|
30
|
+
cui_controlled_by: none,
|
|
31
|
+
cui_category: none,
|
|
32
|
+
cui_limited_dissemination: none,
|
|
33
|
+
cui_poc: none,
|
|
34
|
+
footer_tag_line: none,
|
|
35
|
+
memo_style: "usaf",
|
|
36
|
+
it,
|
|
37
|
+
) = {
|
|
38
|
+
assert(subject != none, message: "subject is required")
|
|
39
|
+
assert(memo_for != none, message: "memo_for is required")
|
|
40
|
+
assert(
|
|
41
|
+
memo_style in ("usaf", "daf"),
|
|
42
|
+
message: "memo_style must be \"usaf\" or \"daf\"",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
let actual_date = if date == none { datetime.today() } else { date }
|
|
46
|
+
|
|
47
|
+
// The banner is `LEVEL` or `LEVEL//SUFFIX`. `classification_level` is an enum
|
|
48
|
+
// (a `str`), but `dissemination` is a `plaintext` field and so arrives as
|
|
49
|
+
// content, which `str + str` cannot absorb — the marking is assembled as
|
|
50
|
+
// content instead, keeping the suffix's glyphs click-navigable on every page
|
|
51
|
+
// the banner repeats on.
|
|
52
|
+
let classification_marking = if classification_level == none or type(classification_level) != str {
|
|
53
|
+
none
|
|
54
|
+
} else {
|
|
55
|
+
let base = classification_level.trim()
|
|
56
|
+
if base == "" {
|
|
57
|
+
none
|
|
58
|
+
} else if falsey(dissemination) {
|
|
59
|
+
[#base]
|
|
60
|
+
} else {
|
|
61
|
+
// `//` is a line comment in Typst markup, so the separator is
|
|
62
|
+
// interpolated as a string rather than written literally. The suffix is
|
|
63
|
+
// boxed so the markup block's edge newlines do not read as a space and
|
|
64
|
+
// split the banner into `CUI// NF`.
|
|
65
|
+
let separator = "//"
|
|
66
|
+
[#base#separator#box(upper(dissemination))]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let classification_color = get-classification-level-color(classification_level)
|
|
70
|
+
|
|
71
|
+
// Build the CUI designation indicator block (DoDM 5200.48, Table 1), shown
|
|
72
|
+
// only for CUI when at least one indicator field is set. Rendered as a
|
|
73
|
+
// bottom-right page-1 float (see placement below).
|
|
74
|
+
let cui_indicator = if (
|
|
75
|
+
classification_level != none
|
|
76
|
+
and type(classification_level) == str
|
|
77
|
+
and classification_level.trim().starts-with("CUI")
|
|
78
|
+
) {
|
|
79
|
+
// Each indicator is a `plaintext` field, so it arrives as content when set
|
|
80
|
+
// and as `""` when blank — `falsey` is the shape-agnostic presence test
|
|
81
|
+
// that the former `type(..) == str and ..trim() != ""` guard replaces.
|
|
82
|
+
let lines = ()
|
|
83
|
+
if not falsey(cui_controlled_by) {
|
|
84
|
+
lines.push([Controlled By: #cui_controlled_by])
|
|
85
|
+
}
|
|
86
|
+
if not falsey(cui_category) {
|
|
87
|
+
lines.push([CUI Category: #cui_category])
|
|
88
|
+
}
|
|
89
|
+
if not falsey(cui_limited_dissemination) {
|
|
90
|
+
lines.push([LDC: #upper(cui_limited_dissemination)])
|
|
91
|
+
}
|
|
92
|
+
if not falsey(cui_poc) {
|
|
93
|
+
lines.push([POC: #cui_poc])
|
|
94
|
+
}
|
|
95
|
+
if lines.len() > 0 { lines.join(linebreak()) } else { none }
|
|
96
|
+
} else {
|
|
97
|
+
none
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Document-wide typography settings (inlined from configure())
|
|
101
|
+
set par(leading: spacing.line, spacing: spacing.line, justify: false)
|
|
102
|
+
set block(above: spacing.line, below: 0em, spacing: 0em)
|
|
103
|
+
set text(font: body_font, size: font_size, fallback: true)
|
|
104
|
+
show raw: set text(font: DEFAULT_MONO_FONTS) // Static monospace face for inline code and code blocks
|
|
105
|
+
|
|
106
|
+
set page(
|
|
107
|
+
paper: "us-letter",
|
|
108
|
+
// AFH 33-337 §4: "Use 1-inch margins on the left, right and bottom"
|
|
109
|
+
margin: (
|
|
110
|
+
left: spacing.margin,
|
|
111
|
+
right: spacing.margin,
|
|
112
|
+
top: spacing.margin,
|
|
113
|
+
bottom: spacing.margin,
|
|
114
|
+
),
|
|
115
|
+
header: {
|
|
116
|
+
// AFH 33-337 "Page numbering" §12: "The first page of a memorandum is never numbered.
|
|
117
|
+
// Number the succeeding pages starting with page 2. Place page numbers 0.5-inch from
|
|
118
|
+
// the top of the page, flush with the right margin."
|
|
119
|
+
context if counter(page).get().first() > 1 {
|
|
120
|
+
place(
|
|
121
|
+
dy: +.5in,
|
|
122
|
+
block(
|
|
123
|
+
width: 100%,
|
|
124
|
+
align(right, text(12pt)[#counter(page).display()]),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if classification_marking != none {
|
|
130
|
+
place(
|
|
131
|
+
top + center,
|
|
132
|
+
dy: 0.375in,
|
|
133
|
+
text(12pt, font: DEFAULT_BODY_FONTS, fill: classification_color)[#strong(classification_marking)],
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
footer: {
|
|
138
|
+
if classification_marking != none {
|
|
139
|
+
place(
|
|
140
|
+
bottom + center,
|
|
141
|
+
dy: -.375in,
|
|
142
|
+
text(12pt, font: DEFAULT_BODY_FONTS, fill: classification_color)[#strong(classification_marking)],
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if not falsey(footer_tag_line) {
|
|
147
|
+
place(
|
|
148
|
+
bottom + center,
|
|
149
|
+
dy: -0.625in,
|
|
150
|
+
align(center)[
|
|
151
|
+
#text(fill: LETTERHEAD_COLOR, font: "cinzel", size: 15pt)[#footer_tag_line]
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// DoDM 5200.48 §3: CUI designation indicator block — page 1 only, bottom-right
|
|
159
|
+
// corner, dropped into the 0.5in page-edge band. Emitted as a bottom float so
|
|
160
|
+
// it (1) reserves flow space, raising page 1's effective bottom margin so body
|
|
161
|
+
// text never overlaps it, and (2) stays pinned to page 1 — as the first flow
|
|
162
|
+
// content it can never be bumped to page 2.
|
|
163
|
+
if cui_indicator != none {
|
|
164
|
+
context {
|
|
165
|
+
// The box shrink-wraps to its widest line; `set align(left)` keeps the
|
|
166
|
+
// text flush-left within it, overriding the `align(right)` the placement
|
|
167
|
+
// below imposes.
|
|
168
|
+
let indicator_box = box({
|
|
169
|
+
set text(font: DEFAULT_BODY_FONTS, size: 10pt)
|
|
170
|
+
set par(leading: 0.4em, spacing: 0pt)
|
|
171
|
+
set align(left)
|
|
172
|
+
cui_indicator
|
|
173
|
+
})
|
|
174
|
+
// Reserve only the part of the block inside the text area (`reserved`):
|
|
175
|
+
// float a box of that height, then `place` the full block inside it pushed
|
|
176
|
+
// down by `overhang` so the surplus overflows into the edge band. (A bare
|
|
177
|
+
// `box(height: reserved, indicator_box)` overflows *upward* into the body
|
|
178
|
+
// instead.) The inner `place` adds no size, so the box stays `reserved`
|
|
179
|
+
// tall and the block's bottom lands 0.5in from the page edge.
|
|
180
|
+
let overhang = spacing.margin - 0.5in
|
|
181
|
+
let reserved = measure(indicator_box).height - overhang
|
|
182
|
+
place(
|
|
183
|
+
bottom + right,
|
|
184
|
+
float: true,
|
|
185
|
+
// Slide the right edge into the page-edge band, 0.5in from the border;
|
|
186
|
+
// the inner place right-aligns the block to that edge.
|
|
187
|
+
dx: spacing.margin - 0.5in,
|
|
188
|
+
// Minimum gap to the body's last line; the actual gap is larger when the
|
|
189
|
+
// next paragraph can't fit above the block and breaks to the next page.
|
|
190
|
+
clearance: spacing.line,
|
|
191
|
+
box(height: reserved, place(bottom + right, dy: overhang, indicator_box)),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
render-letterhead(
|
|
197
|
+
letterhead_title,
|
|
198
|
+
letterhead_caption,
|
|
199
|
+
letterhead_font,
|
|
200
|
+
letterhead-seal: letterhead_seal,
|
|
201
|
+
letterhead-seal-subtitle: letterhead_seal_subtitle,
|
|
202
|
+
letterhead-emblem: letterhead_emblem,
|
|
203
|
+
letterhead-emblem-height: letterhead_emblem_height,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
// AFH 33-337 "Date": "Place the date 1 inch from the right edge, 1.75 inches from the top"
|
|
207
|
+
// Since we have a 1-inch top margin, we need (1.75in - margin) vertical space
|
|
208
|
+
v(1.75in - spacing.margin)
|
|
209
|
+
|
|
210
|
+
// Measure and cache body line stride once for body line-count heuristics.
|
|
211
|
+
context {
|
|
212
|
+
let one-line = measure(par(spacing: 0pt)[x]).height
|
|
213
|
+
let line-stride = measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
|
|
214
|
+
LINE_STRIDE.update(line-stride)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
[#metadata((
|
|
218
|
+
subject: subject,
|
|
219
|
+
original_date: actual_date,
|
|
220
|
+
original_from: first-or-value(memo_from),
|
|
221
|
+
body_font: body_font,
|
|
222
|
+
font_size: font_size,
|
|
223
|
+
memo_style: memo_style,
|
|
224
|
+
)) <usaf-memo-config>]
|
|
225
|
+
|
|
226
|
+
render-date-section(actual_date, memo-style: memo_style)
|
|
227
|
+
render-for-section(memo_for, memo_for_cols)
|
|
228
|
+
if not falsey(memo_from) { render-from-section(memo_from) }
|
|
229
|
+
// Blank entries are dropped first so a stub `- ` left under `references:`
|
|
230
|
+
// cannot pass as the lone reference and render an empty `()` after the subject.
|
|
231
|
+
let refs = compact-references(references)
|
|
232
|
+
let single-ref = if refs.len() == 1 { refs.at(0) } else { none }
|
|
233
|
+
render-subject-section(subject, inline-reference: single-ref)
|
|
234
|
+
render-references-section(refs)
|
|
235
|
+
|
|
236
|
+
// AFH 33-337: "Begin text on second line below subject/references".
|
|
237
|
+
// Emitted here (not inside body.typ) so the v() lands at the same lexical
|
|
238
|
+
// level as the preceding header sections and combines correctly with their
|
|
239
|
+
// block spacing.
|
|
240
|
+
blank-line()
|
|
241
|
+
it
|
|
242
|
+
}
|