@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.
- package/README.md +63 -21
- package/package.json +9 -7
- package/quills/af4141/0.1.0/Quill.yaml +4 -13
- package/quills/af4141/0.1.0/example.md +53 -0
- package/quills/afmc_moa/0.0.1/Quill.yaml +39 -62
- package/quills/afmc_moa/0.0.1/example.md +114 -0
- package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/config.typ +1 -1
- package/quills/classic_resume/0.0.1/Quill.yaml +281 -0
- package/quills/classic_resume/0.0.1/example.md +111 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/LICENSE +21 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Bold.ttf +0 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-BoldItalic.ttf +0 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Italic.ttf +0 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Regular.ttf +0 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/OFL.txt +94 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/components.typ +214 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/config.typ +61 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/layout.typ +86 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/lib.typ +7 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/util.typ +37 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/typst.toml +26 -0
- package/quills/classic_resume/0.0.1/plate.typ +143 -0
- package/quills/cyber_ribbon_chart/0.0.1/Quill.yaml +327 -0
- package/quills/cyber_ribbon_chart/0.0.1/example.md +55 -0
- package/quills/cyber_ribbon_chart/0.0.1/plate.typ +609 -0
- package/quills/daf1206/0.1.0/Quill.yaml +15 -18
- package/quills/daf1206/0.1.0/example.md +55 -0
- package/quills/daf4392/0.1.0/Quill.yaml +16 -7
- package/quills/daf4392/0.1.0/example.md +51 -0
- package/quills/daf4392/0.1.0/plate.typ +2 -2
- package/quills/usaf_letter/0.1.0/Quill.yaml +179 -0
- package/quills/usaf_letter/0.1.0/assets/dod_seal.png +0 -0
- package/quills/usaf_letter/0.1.0/assets/dow_seal.png +0 -0
- package/quills/usaf_letter/0.1.0/example.md +35 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/.gitignore +7 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/LICENSE +201 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/CopperplateCC-Bold.otf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/LICENSE.md +79 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LICENSE +102 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LiberationMono-Regular.ttf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
- 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
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/backmatter.typ +32 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/config.typ +45 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/frontmatter.typ +226 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/lib.typ +35 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/mainmatter.typ +140 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/primitives.typ +544 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/utils.typ +334 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/typst.toml +22 -0
- package/quills/usaf_letter/0.1.0/plate.typ +121 -0
- package/quills/usaf_memo/0.2.0/Quill.yaml +8 -41
- package/quills/usaf_memo/0.2.0/example.md +45 -0
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/typst.toml +1 -1
- package/quills/usaf_memo/0.3.0/Quill.yaml +51 -69
- package/quills/usaf_memo/0.3.0/example.md +48 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/OFL.txt +93 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +29 -17
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +97 -126
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/config.typ +12 -45
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +81 -87
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +58 -66
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +14 -36
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +47 -19
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +158 -111
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +41 -141
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/typst.toml +3 -8
- package/quills/usaf_memo/0.3.0/plate.typ +78 -85
- package/templates/af4141.md +23 -0
- package/templates/appointment_letter.md +58 -0
- package/templates/daf1206.md +23 -0
- package/templates/daf4392.md +34 -0
- package/templates/deros_extension.md +40 -0
- package/templates/pass_request.md +48 -0
- package/templates/rebuttal.md +63 -0
- package/templates/templates.json +65 -0
- package/templates/usaf_template.md +30 -0
- package/templates/ussf_template.md +30 -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/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Spacing, normalization, and formatting helpers shared across the template.
|
|
2
|
+
|
|
3
|
+
#import "config.typ": CLASSIFICATION_COLORS, counters, paragraph-config, spacing
|
|
4
|
+
|
|
5
|
+
// Measured height of one wrapped body line, set once in `frontmatter`.
|
|
6
|
+
#let LINE_STRIDE = state("LINE_STRIDE")
|
|
7
|
+
|
|
8
|
+
/// The height one wrapped line of body text occupies.
|
|
9
|
+
///
|
|
10
|
+
/// Read from `LINE_STRIDE` (cached in `frontmatter`), falling back to an inline
|
|
11
|
+
/// measurement when the cache is unset. Reads state, so callers must already be
|
|
12
|
+
/// in a `context`.
|
|
13
|
+
///
|
|
14
|
+
/// -> length
|
|
15
|
+
#let line-stride() = {
|
|
16
|
+
let stride = LINE_STRIDE.get()
|
|
17
|
+
if stride == none {
|
|
18
|
+
let one-line = measure(par(spacing: 0pt)[x]).height
|
|
19
|
+
stride = measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
|
|
20
|
+
}
|
|
21
|
+
stride
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Creates vertical spacing equivalent to multiple blank lines.
|
|
25
|
+
///
|
|
26
|
+
/// Adds `count` wrapped-line strides on top of the natural inter-paragraph
|
|
27
|
+
/// gap, so a blank line occupies exactly the same vertical space as a line
|
|
28
|
+
/// produced by natural paragraph wrapping.
|
|
29
|
+
///
|
|
30
|
+
/// - count (int): Number of blank lines to create
|
|
31
|
+
/// -> content
|
|
32
|
+
#let blank-lines(count) = {
|
|
33
|
+
if count <= 0 { return }
|
|
34
|
+
context { v(line-stride() * count) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Creates vertical spacing equivalent to one blank line.
|
|
38
|
+
///
|
|
39
|
+
/// -> content
|
|
40
|
+
#let blank-line() = blank-lines(1)
|
|
41
|
+
|
|
42
|
+
/// Checks if a value is "falsey" (none, false, empty array, empty string, or
|
|
43
|
+
/// empty content).
|
|
44
|
+
///
|
|
45
|
+
/// A caller may hand a field over as a `str` or as content, so both empty
|
|
46
|
+
/// shapes count: `""` and `[]`. Empty content is what an emptied markup block
|
|
47
|
+
/// and `join()`-over-nothing both produce.
|
|
48
|
+
///
|
|
49
|
+
/// - value (any): The value to check for "falsey" status
|
|
50
|
+
/// -> bool
|
|
51
|
+
#let falsey(value) = {
|
|
52
|
+
(
|
|
53
|
+
value == none
|
|
54
|
+
or value == false
|
|
55
|
+
or (type(value) == array and value.len() == 0)
|
|
56
|
+
or (type(value) == str and value == "")
|
|
57
|
+
or value == []
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Scales content uniformly to fit a fixed-size box, preserving aspect ratio.
|
|
62
|
+
///
|
|
63
|
+
/// Used for letterhead seals and emblems, whose source images vary in size.
|
|
64
|
+
///
|
|
65
|
+
/// - width (length): Maximum width for the content (default: 2in)
|
|
66
|
+
/// - height (length): Maximum height for the content (default: 1in)
|
|
67
|
+
/// - alignment (alignment): Content alignment within the box (default: left+horizon)
|
|
68
|
+
/// - body (content): Content to scale and fit
|
|
69
|
+
/// -> content
|
|
70
|
+
#let fit-box(width: 2in, height: 1in, alignment: left + horizon, body) = context {
|
|
71
|
+
let s = measure(body)
|
|
72
|
+
let f = calc.min(width / s.width, height / s.height) * 100% // ratio
|
|
73
|
+
// `reflow: true` so layout respects the scaled size rather than the original.
|
|
74
|
+
box(width: width, height: height, clip: true)[
|
|
75
|
+
#align(alignment)[
|
|
76
|
+
#scale(f, reflow: true)[#body]
|
|
77
|
+
]
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Shrinks content to fit within a maximum width, never enlarging it.
|
|
82
|
+
///
|
|
83
|
+
/// Measures `body` at its current font size and scales it down uniformly only
|
|
84
|
+
/// when it is wider than `width`; content that already fits keeps its set size
|
|
85
|
+
/// (the scale is capped at 100%). Unlike `fit-box`, height flows naturally and
|
|
86
|
+
/// small content is never scaled up. Used for the letterhead caption (unit
|
|
87
|
+
/// designation): a long name is shrunk to the width that clears the seal rather
|
|
88
|
+
/// than running underneath it. Pass a non-wrapping `box` as `body` so the
|
|
89
|
+
/// measured width is the single-line width and the result stays on one line.
|
|
90
|
+
///
|
|
91
|
+
/// Empty content measures 0pt wide and has no scale to compute, so it is returned
|
|
92
|
+
/// as-is rather than divided by; a non-positive `width` likewise admits no scale
|
|
93
|
+
/// that fits, so the body is left at its set size instead of being flipped or
|
|
94
|
+
/// collapsed.
|
|
95
|
+
///
|
|
96
|
+
/// - width (length): Maximum width the content may occupy
|
|
97
|
+
/// - alignment (alignment): Horizontal placement within the reserved width
|
|
98
|
+
/// - body (content): Content to fit (typically a non-wrapping `box`)
|
|
99
|
+
/// -> content
|
|
100
|
+
#let fit-to-width(width, alignment: left, body) = context {
|
|
101
|
+
let s = measure(body)
|
|
102
|
+
if s.width <= 0pt or width <= 0pt { return body }
|
|
103
|
+
let f = calc.min(1, width / s.width) * 100% // ratio, capped so it only shrinks
|
|
104
|
+
box(width: width)[
|
|
105
|
+
#align(alignment)[
|
|
106
|
+
#scale(f, reflow: true)[#body]
|
|
107
|
+
]
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// The date pattern a memo style prints, per AFH 33-337. Exported so a caller
|
|
112
|
+
/// that pre-formats the date matches this package instead of restating it.
|
|
113
|
+
///
|
|
114
|
+
/// - memo-style (str): `"usaf"` or `"daf"`
|
|
115
|
+
/// -> str
|
|
116
|
+
#let date-pattern(memo-style: "usaf") = if memo-style == "daf" {
|
|
117
|
+
"[month repr:long] [day padding:none], [year]"
|
|
118
|
+
} else {
|
|
119
|
+
"[day padding:none] [month repr:long] [year]"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Formats a date for the memo heading.
|
|
123
|
+
///
|
|
124
|
+
/// - str, content: shown as-is (a fixed placeholder, or ink the caller already
|
|
125
|
+
/// formatted through [`date-pattern`]).
|
|
126
|
+
/// - datetime: USAF style `DD Month YYYY`; DAF style `Month DD, YYYY`.
|
|
127
|
+
///
|
|
128
|
+
/// - date (str | content | datetime): Date to format for display
|
|
129
|
+
/// - memo-style (str): `"usaf"` or `"daf"`
|
|
130
|
+
/// -> content
|
|
131
|
+
#let display-date(date, memo-style: "usaf") = {
|
|
132
|
+
assert(
|
|
133
|
+
memo-style in ("usaf", "daf"),
|
|
134
|
+
message: "memo-style for display-date must be \"usaf\" or \"daf\"",
|
|
135
|
+
)
|
|
136
|
+
// A caller that pre-formatted — to keep a click-to-edit region the package
|
|
137
|
+
// cannot mint itself — passes the finished ink through untouched.
|
|
138
|
+
if type(date) in (str, content) {
|
|
139
|
+
date
|
|
140
|
+
} else {
|
|
141
|
+
date.display(date-pattern(memo-style: memo-style))
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Reserves the space a date occupies, for a date unknown at compile time.
|
|
146
|
+
///
|
|
147
|
+
/// Used for indorsements whose signing date is not known when the memo is
|
|
148
|
+
/// rendered: the endorser supplies it after the fact. The reserved box is one
|
|
149
|
+
/// line tall and sits on the baseline, so the slot lines up with where a
|
|
150
|
+
/// printed date would sit and takes exactly the space one would take.
|
|
151
|
+
///
|
|
152
|
+
/// `field` is the caller's fill-in widget, an interactive element such as a
|
|
153
|
+
/// PDF form field. It is anchored at the slot's top-left corner, the corner
|
|
154
|
+
/// such a widget's own rectangle grows right and down from, so one declared at
|
|
155
|
+
/// this slot's dimensions covers it exactly. With no widget to anchor, the slot
|
|
156
|
+
/// rules its baseline instead, for the endorser to date by hand.
|
|
157
|
+
///
|
|
158
|
+
/// - field (content | none): Fill-in widget to anchor in the slot
|
|
159
|
+
/// - width (length): Width of the slot; defaults to fit a long date such as
|
|
160
|
+
/// "15 September 2026".
|
|
161
|
+
/// -> content
|
|
162
|
+
#let date-placeholder-slot(field: none, width: 1in) = box(
|
|
163
|
+
width: width,
|
|
164
|
+
height: 1em,
|
|
165
|
+
// Keep the slot's bottom edge on the line's baseline, where the printed date
|
|
166
|
+
// would sit; the 1em height then reserves the line above it — the space a
|
|
167
|
+
// handwritten date is written in.
|
|
168
|
+
// (A positive baseline shift would drop the box a full line too low.)
|
|
169
|
+
baseline: 0pt,
|
|
170
|
+
stroke: if field == none { (bottom: 0.5pt + black) },
|
|
171
|
+
if field != none { place(top + left, field) },
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
/// Gets the banner color for a classification marking.
|
|
175
|
+
///
|
|
176
|
+
/// Matches when `level` (trimmed) starts with a known prefix: TOP SECRET, SECRET,
|
|
177
|
+
/// CONFIDENTIAL, CUI, or UNCLASSIFIED. Otherwise returns black.
|
|
178
|
+
///
|
|
179
|
+
/// - level (str): Marking string shown in header/footer
|
|
180
|
+
/// -> color
|
|
181
|
+
#let get-classification-level-color(level) = {
|
|
182
|
+
if level == none or type(level) != str {
|
|
183
|
+
return rgb(0, 0, 0)
|
|
184
|
+
}
|
|
185
|
+
let s = level.trim()
|
|
186
|
+
// Longest-prefix-first so e.g. "TOP SECRET" wins over "SECRET".
|
|
187
|
+
let level-order = ("TOP SECRET", "SECRET", "CONFIDENTIAL", "CUI", "UNCLASSIFIED")
|
|
188
|
+
for base-level in level-order {
|
|
189
|
+
if s.starts-with(base-level) {
|
|
190
|
+
return CLASSIFICATION_COLORS.at(base-level)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
rgb(0, 0, 0)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Arranges one cell or an array of them into a `cols`-wide grid, filled
|
|
197
|
+
/// row-major. Used for the MEMORANDUM FOR recipient list.
|
|
198
|
+
///
|
|
199
|
+
/// Cells may be content as readily as `str`, so a caller that builds each
|
|
200
|
+
/// recipient as markup — to keep the rendered glyphs traceable to their
|
|
201
|
+
/// source — passes it through unflattened.
|
|
202
|
+
///
|
|
203
|
+
/// - content (str | content | array): Cell(s) to arrange in grid
|
|
204
|
+
/// - column-gutter (length): Space between columns (default: 0.5em)
|
|
205
|
+
/// - cols (int): Number of columns for the grid (default: 3)
|
|
206
|
+
/// -> grid
|
|
207
|
+
#let create-auto-grid(content, column-gutter: .5em, cols: 3) = {
|
|
208
|
+
let content-type = type(content)
|
|
209
|
+
|
|
210
|
+
if content-type != array {
|
|
211
|
+
content = (content,)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
// Each row carries a trailing empty cell, so a row is `cols + 1` wide — the
|
|
216
|
+
// width both this loop and the tail padding below count against.
|
|
217
|
+
let cells = ()
|
|
218
|
+
let i = 0
|
|
219
|
+
for item in content {
|
|
220
|
+
i += 1
|
|
221
|
+
cells.push(item)
|
|
222
|
+
if calc.rem(i, cols) == 0 {
|
|
223
|
+
cells.push([])
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let remainder = calc.rem(cells.len(), cols + 1)
|
|
228
|
+
if remainder != 0 {
|
|
229
|
+
let padding-needed = (cols + 1) - remainder
|
|
230
|
+
for _ in range(padding-needed) {
|
|
231
|
+
cells.push([])
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
grid(
|
|
236
|
+
columns: calc.max(1, cols) + 1,
|
|
237
|
+
column-gutter: .1fr,
|
|
238
|
+
row-gutter: spacing.line,
|
|
239
|
+
..cells
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// Normalizes a value to an array, wrapping a scalar and mapping `none` to `()`.
|
|
244
|
+
///
|
|
245
|
+
/// - value: Any value to normalize to array form
|
|
246
|
+
/// -> array
|
|
247
|
+
#let ensure-array(value) = {
|
|
248
|
+
if value == none {
|
|
249
|
+
()
|
|
250
|
+
} else if type(value) == array {
|
|
251
|
+
value
|
|
252
|
+
} else {
|
|
253
|
+
(value,)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// Normalizes a scalar-or-array field to a single stacked-lines **content**
|
|
258
|
+
/// value, one array element per line.
|
|
259
|
+
///
|
|
260
|
+
/// A field may arrive as a `str` or as content, and `str(..)` on content is an
|
|
261
|
+
/// error. Joining with `linebreak()` rather than `"\n"` renders the same
|
|
262
|
+
/// stacked lines while accepting either shape.
|
|
263
|
+
///
|
|
264
|
+
/// A blank field lands as empty content, which stays `falsey` for callers that
|
|
265
|
+
/// test the result.
|
|
266
|
+
///
|
|
267
|
+
/// - value (any): Scalar or array of scalars/content to stack
|
|
268
|
+
/// -> content
|
|
269
|
+
///
|
|
270
|
+
/// Examples:
|
|
271
|
+
/// - join-lines(([a], [b])) → [a] + linebreak() + [b]
|
|
272
|
+
/// - join-lines(none) → []
|
|
273
|
+
#let join-lines(value) = {
|
|
274
|
+
if value == none { return [] }
|
|
275
|
+
let items = if type(value) == array { value } else { (value,) }
|
|
276
|
+
// A `str` element is wrapped so the join is content-to-content throughout;
|
|
277
|
+
// mixing `str` and content under `+` is not a legal Typst addition.
|
|
278
|
+
let joined = items.map(item => [#item]).join(linebreak())
|
|
279
|
+
// `().join(..)` is `none`, not `[]` — coerce so an empty array stays `falsey`.
|
|
280
|
+
if joined == none { [] } else { joined }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// The first element of an array, or the value itself when it is not one.
|
|
284
|
+
/// An empty array and `none` both yield `none`.
|
|
285
|
+
///
|
|
286
|
+
/// - value: Any value to extract from
|
|
287
|
+
/// -> any
|
|
288
|
+
#let first-or-value(value) = {
|
|
289
|
+
if value == none {
|
|
290
|
+
none
|
|
291
|
+
} else if type(value) == array {
|
|
292
|
+
if value.len() > 0 {
|
|
293
|
+
value.at(0)
|
|
294
|
+
} else {
|
|
295
|
+
none
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
value
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
/// The ordinal suffix for an indorsement number: 1st, 2d, 3d, 4th, …
|
|
304
|
+
///
|
|
305
|
+
/// Military correspondence writes "2d" and "3d" where ordinary English writes
|
|
306
|
+
/// "2nd" and "3rd"; 11th through 13th take "th" as usual.
|
|
307
|
+
///
|
|
308
|
+
/// - number (int): The indorsement number (1, 2, 3, etc.)
|
|
309
|
+
/// -> str
|
|
310
|
+
#let get-ordinal-suffix(number) = {
|
|
311
|
+
let last-digit = calc.rem(number, 10)
|
|
312
|
+
let last-two-digits = calc.rem(number, 100)
|
|
313
|
+
|
|
314
|
+
if last-two-digits >= 11 and last-two-digits <= 13 {
|
|
315
|
+
"th"
|
|
316
|
+
} else if last-digit == 1 {
|
|
317
|
+
"st"
|
|
318
|
+
} else if last-digit == 2 {
|
|
319
|
+
"d"
|
|
320
|
+
} else if last-digit == 3 {
|
|
321
|
+
"d"
|
|
322
|
+
} else {
|
|
323
|
+
"th"
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// An indorsement's header label: "1st Ind", "2d Ind", "3d Ind", …
|
|
328
|
+
///
|
|
329
|
+
/// - number (int): Indorsement sequence number (1, 2, 3, etc.)
|
|
330
|
+
/// -> str
|
|
331
|
+
#let format-indorsement-number(number) = {
|
|
332
|
+
let suffix = get-ordinal-suffix(number)
|
|
333
|
+
str(number) + suffix + " Ind"
|
|
334
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "tonguetoquill-usaf-letter"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
compiler = "0.15.1"
|
|
5
|
+
entrypoint = "src/lib.typ"
|
|
6
|
+
repository = "https://github.com/tonguetoquill/airmark-quiver"
|
|
7
|
+
authors = ["tonguetoquill"]
|
|
8
|
+
license = "Apache-2.0"
|
|
9
|
+
description = "Typeset USAF personal letters per AFH 33-337 'The Tongue and Quill'."
|
|
10
|
+
keywords = [
|
|
11
|
+
"USAF",
|
|
12
|
+
"Air Force",
|
|
13
|
+
"personal letter",
|
|
14
|
+
"letter",
|
|
15
|
+
"military",
|
|
16
|
+
"AFH 33-337",
|
|
17
|
+
"official",
|
|
18
|
+
"correspondence",
|
|
19
|
+
"formatting",
|
|
20
|
+
]
|
|
21
|
+
categories = ["office"]
|
|
22
|
+
disciplines = ["politics", "business", "education"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#import "@local/quillmark-helper:0.1.0": (
|
|
2
|
+
data, display, form-field, signature-field,
|
|
3
|
+
)
|
|
4
|
+
#import "@local/tonguetoquill-usaf-letter:0.1.0": (
|
|
5
|
+
backmatter, date-pattern, frontmatter, mainmatter,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// The letterhead arrives as one ordered array of lines. Element 0 is the title,
|
|
9
|
+
// set larger; the rest are caption lines beneath it. A single element renders
|
|
10
|
+
// the title alone — the package skips a falsey caption. An empty array renders
|
|
11
|
+
// no letterhead text at all, silently: the block is `place`d, so an empty title
|
|
12
|
+
// leaves no glyphs and takes no space from the flow, and the seal stands alone.
|
|
13
|
+
#let letterhead_lines = data.letterhead_title
|
|
14
|
+
|
|
15
|
+
// Body text size, in points. Also the height of a blank date's fill-in widget.
|
|
16
|
+
#let body_font_size = data.font_size * 1pt
|
|
17
|
+
|
|
18
|
+
// A blank date's fill-in slot: an empty AcroForm text box the signer types the
|
|
19
|
+
// date into, set in the body face and flush right, as the printed date would
|
|
20
|
+
// be. Wide enough for the longest date, "30 September 2026", which runs 8em in
|
|
21
|
+
// the body face.
|
|
22
|
+
//
|
|
23
|
+
// The slot claims a cap-height, the extent of a printed date, and the widget
|
|
24
|
+
// overhangs it about the line's middle, so a blank date takes no more room from
|
|
25
|
+
// the flow than a printed one and the page lays out the same either way.
|
|
26
|
+
#let date_slot_width = body_font_size * 8.5
|
|
27
|
+
#let date_slot(name, field) = context box(
|
|
28
|
+
width: date_slot_width,
|
|
29
|
+
height: measure[0].height,
|
|
30
|
+
place(horizon + left, form-field(
|
|
31
|
+
name,
|
|
32
|
+
type: "text",
|
|
33
|
+
width: date_slot_width,
|
|
34
|
+
height: body_font_size,
|
|
35
|
+
field: field,
|
|
36
|
+
font: "times",
|
|
37
|
+
size: body_font_size,
|
|
38
|
+
align: "right",
|
|
39
|
+
)),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
#show: frontmatter.with(
|
|
43
|
+
letterhead-title: letterhead_lines.at(0, default: ""),
|
|
44
|
+
letterhead-caption: if letterhead_lines.len() > 1 { letterhead_lines.slice(1) } else { () },
|
|
45
|
+
letterhead-seal-subtitle: data.letterhead_seal_subtitle,
|
|
46
|
+
// A letter has no seal-less state, so the blank takes the default, DoW.
|
|
47
|
+
letterhead-seal: image(
|
|
48
|
+
if data.letterhead_seal == "dod" {
|
|
49
|
+
"assets/dod_seal.png"
|
|
50
|
+
} else {
|
|
51
|
+
"assets/dow_seal.png"
|
|
52
|
+
}
|
|
53
|
+
),
|
|
54
|
+
|
|
55
|
+
// Date. `data.date` is the native `datetime` and would render identically,
|
|
56
|
+
// but its ink would be born inside the package and carry no schema address.
|
|
57
|
+
// `display` places the field's *content* projection instead: the glyphs are
|
|
58
|
+
// born in the generated helper, so the letter's date stays click-to-edit
|
|
59
|
+
// however deep the package formats it.
|
|
60
|
+
//
|
|
61
|
+
// A blank date is left for the signer: a letter is dated when it is signed,
|
|
62
|
+
// which is generally not when it is rendered, so the slot is fillable rather
|
|
63
|
+
// than stamped with the compile date. Passing it as the date also keeps it
|
|
64
|
+
// from `frontmatter`'s own `datetime.today()` fallback.
|
|
65
|
+
date: {
|
|
66
|
+
let authored = display("date", date-pattern())
|
|
67
|
+
if authored != none { authored } else { date_slot("Date", "date") }
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
letter-from: data.letter_from,
|
|
71
|
+
letter-for: data.letter_for,
|
|
72
|
+
salutation: data.salutation,
|
|
73
|
+
|
|
74
|
+
footer-tag-line: data.tag_line,
|
|
75
|
+
|
|
76
|
+
// The blank reads as no banner, which is what the package's own
|
|
77
|
+
// `classification-level: none` default means.
|
|
78
|
+
classification-level: data.classification.value,
|
|
79
|
+
|
|
80
|
+
dissemination: data.dissemination,
|
|
81
|
+
|
|
82
|
+
// CUI designation indicator block fields (DoDM 5200.48). `classification`
|
|
83
|
+
// declares a `CUI` variant, so these four exist only where the discriminant
|
|
84
|
+
// reads CUI, and the branch is what makes reading them total: inside it every
|
|
85
|
+
// declared field of that world is present, outside it none is. The package's
|
|
86
|
+
// own `cui_*: none` defaults cover the worlds that omit them.
|
|
87
|
+
..if data.classification.value == "CUI" {
|
|
88
|
+
(
|
|
89
|
+
cui-controlled-by: data.classification.controlled_by,
|
|
90
|
+
cui-category: data.classification.category,
|
|
91
|
+
cui-limited-dissemination: data.classification.limited_dissemination,
|
|
92
|
+
cui-poc: data.classification.poc,
|
|
93
|
+
)
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
font-size: body_font_size,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// An empty body reaches the plate as `""`, and `mainmatter` would still set a
|
|
100
|
+
// blank paragraph for it, dropping the close two lines too far.
|
|
101
|
+
#if data.at("$body") != "" {
|
|
102
|
+
mainmatter[#data.at("$body")]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#backmatter(
|
|
106
|
+
complimentary-close: data.complimentary_close,
|
|
107
|
+
signature-block: data.signature_block,
|
|
108
|
+
// The widget sits at the bottom of AFH 33-337's four blank lines and is
|
|
109
|
+
// sized to two and a half of them, so the line and a half above it stays
|
|
110
|
+
// clear of the complimentary close without moving the block off the fifth
|
|
111
|
+
// line.
|
|
112
|
+
signing-field: signature-field(
|
|
113
|
+
"Signature",
|
|
114
|
+
field: "signature_block",
|
|
115
|
+
height: body_font_size * 2.5,
|
|
116
|
+
),
|
|
117
|
+
|
|
118
|
+
..if data.attachments.len() > 0 { (attachments: data.attachments) },
|
|
119
|
+
|
|
120
|
+
..if data.cc.len() > 0 { (cc: data.cc) },
|
|
121
|
+
)
|
|
@@ -8,21 +8,14 @@ typst:
|
|
|
8
8
|
plate_file: plate.typ
|
|
9
9
|
|
|
10
10
|
main:
|
|
11
|
+
description: Top-level body paragraphs are numbered and nested bullets lettered automatically; do not number them by hand.
|
|
11
12
|
ui:
|
|
12
13
|
groups: [addressing, letterhead, classification, additional]
|
|
13
|
-
body:
|
|
14
|
-
example: |
|
|
15
|
-
The first paragraph. Top-level paragraphs are auto-numbered; do not add manual numbering.
|
|
16
|
-
|
|
17
|
-
- Nested bullets are automatically lettered.
|
|
18
14
|
fields:
|
|
19
15
|
memo_for:
|
|
20
16
|
type: array
|
|
21
17
|
items:
|
|
22
18
|
type: string
|
|
23
|
-
example:
|
|
24
|
-
- ORG1/SYMBOL
|
|
25
|
-
- ORG2/SYMBOL
|
|
26
19
|
ui:
|
|
27
20
|
group: addressing
|
|
28
21
|
compact: true
|
|
@@ -33,19 +26,13 @@ main:
|
|
|
33
26
|
items:
|
|
34
27
|
type: string
|
|
35
28
|
default: []
|
|
36
|
-
example:
|
|
37
|
-
- ORG/SYMBOL
|
|
38
|
-
- Organization Name
|
|
39
|
-
- 123 Street Ave
|
|
40
|
-
- City ST 12345-6789
|
|
41
29
|
ui:
|
|
42
30
|
group: addressing
|
|
43
31
|
compact: true
|
|
44
|
-
description: "If recipients are on the same installation, use only the office symbol. For recipients on other installations, include the full mailing address to enable return correspondence. Omit entirely to produce a Memorandum for Record (no MEMO FROM line)."
|
|
32
|
+
description: "If recipients are on the same installation, use only the office symbol. For recipients on other installations, include the full mailing address, one line each, ending 'City ST 12345-6789', to enable return correspondence. Omit entirely to produce a Memorandum for Record (no MEMO FROM line)."
|
|
45
33
|
|
|
46
34
|
subject:
|
|
47
35
|
type: string
|
|
48
|
-
example: Subject of the Memorandum
|
|
49
36
|
ui:
|
|
50
37
|
group: addressing
|
|
51
38
|
description: Be brief and clear. Capitalize the first letter of each word except articles, prepositions, and conjunctions. Include suspense dates in parentheses if applicable.
|
|
@@ -54,13 +41,10 @@ main:
|
|
|
54
41
|
type: array
|
|
55
42
|
items:
|
|
56
43
|
type: string
|
|
57
|
-
example:
|
|
58
|
-
- "FIRST M. LAST, Rank, USAF"
|
|
59
|
-
- Duty Title
|
|
60
44
|
ui:
|
|
61
45
|
group: addressing
|
|
62
46
|
compact: true
|
|
63
|
-
description: "Line 1: Name in UPPERCASE as signed, grade, and service. Line 2: Duty title."
|
|
47
|
+
description: "Line 1: Name in UPPERCASE as signed, grade, and service, as in 'FIRST M. LAST, Capt, USAF'. Line 2: Duty title."
|
|
64
48
|
|
|
65
49
|
letterhead_title:
|
|
66
50
|
type: string
|
|
@@ -73,11 +57,9 @@ main:
|
|
|
73
57
|
type: array
|
|
74
58
|
items:
|
|
75
59
|
type: string
|
|
76
|
-
example:
|
|
77
|
-
- HEADQUARTERS [UNIT NAME]
|
|
78
60
|
ui:
|
|
79
61
|
group: letterhead
|
|
80
|
-
description: The full organization name of your unit.
|
|
62
|
+
description: The full organization name of your unit, as in HEADQUARTERS 123D EXAMPLE WING.
|
|
81
63
|
|
|
82
64
|
letterhead_seal:
|
|
83
65
|
type: enum
|
|
@@ -167,9 +149,6 @@ main:
|
|
|
167
149
|
type: richtext
|
|
168
150
|
inline: true
|
|
169
151
|
default: []
|
|
170
|
-
example:
|
|
171
|
-
- "AFMAN 33-326, 25 November 2011, *Preparing Official Communications*"
|
|
172
|
-
- "AFI 33-360, *Publications and Forms Management*"
|
|
173
152
|
ui:
|
|
174
153
|
group: additional
|
|
175
154
|
description: "References, auto-lettered (a), (b), (c) per AFH 33-337. Cite each by organization, type, date, and title; italicize publication titles with *asterisks* (standard Markdown emphasis)."
|
|
@@ -179,20 +158,15 @@ main:
|
|
|
179
158
|
items:
|
|
180
159
|
type: string
|
|
181
160
|
default: []
|
|
182
|
-
example:
|
|
183
|
-
- Rank and Name, ORG/SYMBOL
|
|
184
161
|
ui:
|
|
185
162
|
group: additional
|
|
186
|
-
description: List office symbols of recipients to receive copies.
|
|
163
|
+
description: List office symbols of recipients to receive copies; for a person, 'Rank and Name, ORG/SYMBOL'.
|
|
187
164
|
|
|
188
165
|
distribution:
|
|
189
166
|
type: array
|
|
190
167
|
items:
|
|
191
168
|
type: string
|
|
192
169
|
default: []
|
|
193
|
-
example:
|
|
194
|
-
- ORG1/SYMBOL
|
|
195
|
-
- ORG2/SYMBOL
|
|
196
170
|
ui:
|
|
197
171
|
group: additional
|
|
198
172
|
description: Complete list of recipients if 'SEE DISTRIBUTION' is used in the 'Memo For' field.
|
|
@@ -202,11 +176,9 @@ main:
|
|
|
202
176
|
items:
|
|
203
177
|
type: string
|
|
204
178
|
default: []
|
|
205
|
-
example:
|
|
206
|
-
- Attachment description, YYYY MMM DD
|
|
207
179
|
ui:
|
|
208
180
|
group: additional
|
|
209
|
-
description: List attachments in the order they are mentioned in the memo. Briefly describe each; do not use 'as stated' or abbreviations.
|
|
181
|
+
description: List attachments in the order they are mentioned in the memo. Briefly describe each and give its date; do not use 'as stated' or abbreviations.
|
|
210
182
|
|
|
211
183
|
memo_style:
|
|
212
184
|
type: enum
|
|
@@ -237,21 +209,19 @@ main:
|
|
|
237
209
|
|
|
238
210
|
card_kinds:
|
|
239
211
|
indorsement:
|
|
212
|
+
title: Routing indorsement
|
|
240
213
|
description: Chain of routing endorsements. Each endorsement block adds an official response or forwarding action to the original memo.
|
|
241
214
|
ui:
|
|
242
|
-
title: Routing indorsement
|
|
243
215
|
groups: [addressing, additional]
|
|
244
216
|
fields:
|
|
245
217
|
from:
|
|
246
218
|
type: string
|
|
247
|
-
example: ORG/SYMBOL
|
|
248
219
|
ui:
|
|
249
220
|
group: addressing
|
|
250
221
|
compact: true
|
|
251
222
|
description: Office symbol or Rank Name of the endorsing official.
|
|
252
223
|
for:
|
|
253
224
|
type: string
|
|
254
|
-
example: ORG/SYMBOL
|
|
255
225
|
ui:
|
|
256
226
|
group: addressing
|
|
257
227
|
compact: true
|
|
@@ -260,13 +230,10 @@ card_kinds:
|
|
|
260
230
|
type: array
|
|
261
231
|
items:
|
|
262
232
|
type: string
|
|
263
|
-
example:
|
|
264
|
-
- "FIRST M. LAST, Rank, USAF"
|
|
265
|
-
- Duty Title
|
|
266
233
|
ui:
|
|
267
234
|
group: addressing
|
|
268
235
|
compact: true
|
|
269
|
-
description: "Line 1: Name in UPPERCASE as signed, grade, and service. Line 2: Duty title."
|
|
236
|
+
description: "Line 1: Name in UPPERCASE as signed, grade, and service, as in 'FIRST M. LAST, Capt, USAF'. Line 2: Duty title."
|
|
270
237
|
format:
|
|
271
238
|
type: enum
|
|
272
239
|
values:
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
~~~
|
|
2
|
+
$quill: usaf_memo
|
|
3
|
+
memo_for:
|
|
4
|
+
- SEE DISTRIBUTION
|
|
5
|
+
memo_from:
|
|
6
|
+
- 123 CS/SCX
|
|
7
|
+
subject: "Annual Records Management Review (Suspense: 30 October 2026)"
|
|
8
|
+
date: 2026-09-15
|
|
9
|
+
signature_block:
|
|
10
|
+
- JOHN B. SMITH, Maj, USAF
|
|
11
|
+
- Director of Operations
|
|
12
|
+
letterhead_caption:
|
|
13
|
+
- 123D COMMUNICATIONS SQUADRON
|
|
14
|
+
references:
|
|
15
|
+
- "AFMAN 33-326, 25 November 2011, *Preparing Official Communications*"
|
|
16
|
+
- "AFI 33-360, *Publications and Forms Management*"
|
|
17
|
+
cc:
|
|
18
|
+
- 123 WG/CCE
|
|
19
|
+
distribution:
|
|
20
|
+
- 123 OG/CC
|
|
21
|
+
- 123 MXG/CC
|
|
22
|
+
- 123 MSG/CC
|
|
23
|
+
- 123 MDG/CC
|
|
24
|
+
attachments:
|
|
25
|
+
- Records Management Self-Inspection Checklist, 1 September 2026
|
|
26
|
+
~~~
|
|
27
|
+
|
|
28
|
+
Each group reviews every office of record it owns against references (a) and (b) by the suspense in the subject line. Group records custodians confirm two things for each office:
|
|
29
|
+
|
|
30
|
+
- The file plan lists every series the office keeps, with its disposition authority.
|
|
31
|
+
- Files past cutoff are staged for retirement or destruction.
|
|
32
|
+
|
|
33
|
+
Return the checklist at the attachment, signed by the group commander, to 123 CS/SCX. Direct questions to 123 CS/SCX at DSN 555-0142.
|
|
34
|
+
|
|
35
|
+
~~~
|
|
36
|
+
$kind: indorsement
|
|
37
|
+
from: 123 OG/CC
|
|
38
|
+
for: 123 CS/SCX
|
|
39
|
+
date: 2026-10-22
|
|
40
|
+
signature_block:
|
|
41
|
+
- RICHARD C. ROE, Col, USAF
|
|
42
|
+
- Commander
|
|
43
|
+
~~~
|
|
44
|
+
|
|
45
|
+
The 123 OG completed its review on 21 October 2026. All four squadron file plans are current, and 123 OSS retires its two cutoff series by 15 November 2026.
|