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