@tonguetoquill/collection 0.2.3 → 0.2.5-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +39 -39
  3. package/index.d.ts +2 -2
  4. package/index.js +8 -8
  5. package/package.json +41 -41
  6. package/quills/af4141/0.1.0/Quill.yaml +88 -88
  7. package/quills/af4141/0.1.0/design/TASK.md +19 -19
  8. package/quills/af4141/0.1.0/example.md +35 -35
  9. package/quills/af4141/0.1.0/packages/typst-af4141/FIELDS.json +3169 -3169
  10. package/quills/af4141/0.1.0/packages/typst-af4141/form.typ +538 -538
  11. package/quills/af4141/0.1.0/packages/typst-af4141/lib.typ +227 -227
  12. package/quills/af4141/0.1.0/packages/typst-af4141/typst.toml +7 -7
  13. package/quills/af4141/0.1.0/plate.typ +48 -48
  14. package/quills/classic_resume/0.1.0/Quill.yaml +118 -118
  15. package/quills/classic_resume/0.1.0/example.md +232 -232
  16. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/LICENSE +21 -21
  17. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/README.md +38 -38
  18. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/src/components.typ +184 -184
  19. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/src/layout.typ +42 -42
  20. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/src/lib.typ +5 -5
  21. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/typst.toml +26 -26
  22. package/quills/classic_resume/0.1.0/plate.typ +44 -44
  23. package/quills/cmu_letter/0.1.0/.quillignore +30 -30
  24. package/quills/cmu_letter/0.1.0/Quill.yaml +64 -64
  25. package/quills/cmu_letter/0.1.0/assets/cmu-wordmark.svg +174 -174
  26. package/quills/cmu_letter/0.1.0/example.md +30 -30
  27. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/LICENSE +21 -21
  28. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/fonts/README.txt +100 -100
  29. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/backmatter.typ +13 -13
  30. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/config.typ +39 -39
  31. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/frontmatter.typ +72 -72
  32. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/lib.typ +47 -47
  33. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/mainmatter.typ +42 -42
  34. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/primitives.typ +70 -70
  35. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/utils.typ +85 -85
  36. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/typst.toml +17 -17
  37. package/quills/cmu_letter/0.1.0/plate.typ +19 -19
  38. package/quills/daf4392/0.1.0/Quill.yaml +110 -0
  39. package/quills/daf4392/0.1.0/assets/arimo-v35-latin-700.ttf +0 -0
  40. package/quills/daf4392/0.1.0/assets/arimo-v35-latin-700italic.ttf +0 -0
  41. package/quills/daf4392/0.1.0/assets/arimo-v35-latin-italic.ttf +0 -0
  42. package/quills/daf4392/0.1.0/assets/arimo-v35-latin-regular.ttf +0 -0
  43. package/quills/daf4392/0.1.0/assets/page1.png +0 -0
  44. package/quills/daf4392/0.1.0/example.md +33 -0
  45. package/quills/daf4392/0.1.0/packages/daf4392page2_pkg/FIELDS.json +9 -0
  46. package/quills/daf4392/0.1.0/packages/daf4392page2_pkg/form.typ +14 -0
  47. package/quills/daf4392/0.1.0/packages/daf4392page2_pkg/lib.typ +227 -0
  48. package/quills/daf4392/0.1.0/packages/daf4392page2_pkg/out/debug.typ +4 -0
  49. package/quills/daf4392/0.1.0/packages/daf4392page2_pkg/out/example.typ +4 -0
  50. package/quills/daf4392/0.1.0/packages/daf4392page2_pkg/page1.png +0 -0
  51. package/quills/daf4392/0.1.0/packages/daf4392page2_pkg/typst.toml +7 -0
  52. package/quills/daf4392/0.1.0/plate.typ +60 -0
  53. package/quills/taro/0.1.0/Quill.yaml +29 -29
  54. package/quills/taro/0.1.0/example.md +26 -26
  55. package/quills/taro/0.1.0/plate.typ +31 -31
  56. package/quills/usaf_memo/0.1.0/.quillignore +30 -30
  57. package/quills/usaf_memo/0.1.0/Quill.yaml +209 -209
  58. package/quills/usaf_memo/0.1.0/example.md +54 -54
  59. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/LICENSE +21 -21
  60. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE +93 -93
  61. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/LICENSE.md +79 -79
  62. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/GNU General Public License.txt +339 -339
  63. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +28 -28
  64. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/body.typ +332 -332
  65. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/config.typ +63 -63
  66. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +114 -114
  67. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +118 -118
  68. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/lib.typ +55 -55
  69. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +32 -32
  70. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +272 -272
  71. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/utils.typ +377 -377
  72. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/typst.toml +16 -16
  73. package/quills/usaf_memo/0.1.0/plate.typ +74 -74
  74. package/quills/usaf_memo/0.2.0/.quillignore +30 -30
  75. package/quills/usaf_memo/0.2.0/Quill.yaml +219 -219
  76. package/quills/usaf_memo/0.2.0/example.md +55 -55
  77. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/.gitignore +6 -6
  78. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/LICENSE +21 -21
  79. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE +93 -93
  80. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/LICENSE.md +79 -79
  81. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/GNU General Public License.txt +339 -339
  82. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +28 -28
  83. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/body.typ +333 -333
  84. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/config.typ +64 -64
  85. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +114 -114
  86. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +118 -118
  87. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/lib.typ +55 -55
  88. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +32 -32
  89. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +293 -293
  90. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/utils.typ +374 -374
  91. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/typst.toml +27 -27
  92. package/quills/usaf_memo/0.2.0/plate.typ +75 -75
  93. package/templates/af4141.md +88 -88
  94. package/templates/cmu_letter_template.md +37 -37
  95. package/templates/daf4392.md +33 -0
  96. package/templates/loc.md +78 -78
  97. package/templates/pass_request.md +43 -43
  98. package/templates/rebuttal.md +55 -55
  99. package/templates/taro.md +26 -26
  100. package/templates/templates.json +55 -49
  101. package/templates/usaf_template.md +23 -23
  102. package/templates/ussf_template.md +29 -29
@@ -1,377 +1,377 @@
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
- /// Creates vertical spacing equivalent to multiple blank lines.
18
- ///
19
- /// Calculates proper vertical space based on line height and leading
20
- /// to maintain consistent spacing throughout the document.
21
- ///
22
- /// - count (int): Number of blank lines to create
23
- /// - weak (bool): Whether spacing can be compressed at page breaks
24
- /// -> content
25
- #let blank-lines(count, weak: true) = {
26
- if count == 0 {
27
- v(0em, weak: weak)
28
- } else {
29
- let lead = spacing.line
30
- let height = spacing.line-height
31
- v(lead + (height + lead) * count, weak: weak)
32
- }
33
- }
34
-
35
- /// Creates vertical spacing equivalent to one blank line.
36
- /// Convenience function for single line spacing.
37
- ///
38
- /// - weak (bool): Whether spacing can be compressed at page breaks
39
- /// -> content
40
- #let blank-line(weak: true) = blank-lines(1, weak: weak)
41
-
42
- // =============================================================================
43
- // GENERAL UTILITY FUNCTIONS
44
- // =============================================================================
45
-
46
- /// Checks if a value is "falsey" (none, false, empty array, or empty string).
47
- ///
48
- /// Provides a consistent way to test for empty or missing values across
49
- /// the template system. Used for conditional rendering of optional sections.
50
- ///
51
- /// - value (any): The value to check for "falsey" status
52
- /// -> bool
53
- #let falsey(value) = {
54
- value == none or value == false or (type(value) == array and value.len() == 0) or (type(value) == str and value == "")
55
- }
56
-
57
- /// Scales content to fit within a specified box while maintaining aspect ratio.
58
- ///
59
- /// Automatically measures content and calculates uniform scaling to fit within
60
- /// the given dimensions. Commonly used for letterhead seals and other images
61
- /// that need to fit specific size constraints while preserving proportions.
62
- ///
63
- /// - width (length): Maximum width for the content (default: 2in)
64
- /// - height (length): Maximum height for the content (default: 1in)
65
- /// - alignment (alignment): Content alignment within the box (default: left+horizon)
66
- /// - body (content): Content to scale and fit
67
- /// -> content
68
- #let fit-box(width: 2in, height: 1in, alignment: left + horizon, body) = context {
69
- // 1) measure the unscaled content
70
- let s = measure(body)
71
-
72
- // 2) compute the uniform scale that fits inside the box
73
- let f = calc.min(width / s.width, height / s.height) * 100% // ratio
74
-
75
- // 3) fixed-size box, center the scaled content, and reflow so layout respects it
76
- box(width: width, height: height, clip: true)[
77
- #align(alignment)[
78
- #scale(f, reflow: true)[#body]
79
- ]
80
- ]
81
- }
82
-
83
- /// Checks if a string is in ISO date format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS).
84
- ///
85
- /// Performs a simple pattern check for ISO 8601 date strings by verifying:
86
- /// - String is at least 10 characters long
87
- /// - Characters at positions 4 and 7 are dashes
88
- ///
89
- /// - date-str (str): String to check for ISO date pattern
90
- /// -> bool
91
- #let is-iso-date-string(date-str) = {
92
- if date-str.len() == 10 {
93
- let char4 = date-str.at(4)
94
- let char7 = date-str.at(7)
95
- return char4 == "-" and char7 == "-"
96
- } else if date-str.len() > 10 {
97
- let char4 = date-str.at(4)
98
- let char7 = date-str.at(7)
99
- let char10 = date-str.at(10)
100
- return char4 == "-" and char7 == "-" and char10 == "T"
101
- }
102
- return false
103
- }
104
-
105
- /// Extracts the date portion (YYYY-MM-DD) from an ISO date string.
106
- ///
107
- /// Returns the first 10 characters which contain the date portion of an
108
- /// ISO 8601 date string, removing any time component if present.
109
- ///
110
- /// - date-str (str): ISO date string to extract from
111
- /// -> str
112
- #let extract-iso-date(date-str) = {
113
- date-str.slice(0, 10)
114
- }
115
-
116
- /// Formats a date in standard military format or ISO format depending on input type.
117
- ///
118
- /// AFH 33-337 "Date": "Use the 'Day Month Year' or 'DD Mmm YY' format for documents
119
- /// addressed to a military organization. For civilian addressees, use the 'Month Day, Year' format."
120
- /// Examples: "15 October 2014" or "15 Oct 14" for military, "October 15, 2014" for civilian
121
- ///
122
- /// Intelligently handles different date input formats:
123
- /// - ISO string (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS): Parsed via TOML and displayed in military format
124
- /// - Non-ISO string: Displayed as-is
125
- /// - datetime object: Displayed in military format ("1 January 2024")
126
- ///
127
- /// - date (str|datetime): Date to format for display
128
- /// -> str
129
- // NOTE: Consider simplification if Typst adds native ISO date parsing (see CASCADES.md §A)
130
- #let display-date(date) = {
131
- if type(date) == str {
132
- if is-iso-date-string(date) {
133
- // Parse ISO date string using TOML to get datetime object
134
- let iso-date = extract-iso-date(date)
135
- let toml-str = "date = " + iso-date
136
- let parsed = toml(bytes(toml-str))
137
- parsed.date.display("[day padding:none] [month repr:long] [year]")
138
- } else {
139
- date
140
- }
141
- } else {
142
- date.display("[day padding:none] [month repr:long] [year]")
143
- }
144
- }
145
-
146
- /// Gets the color associated with a classification level.
147
- ///
148
- /// - level (str): Classification level string
149
- /// -> color
150
- #let get-classification-level-color(level) = {
151
- if level == none {
152
- return rgb(0, 0, 0) // Default to black if no classification
153
- }
154
- // Order matters - check most specific first
155
- let level-order = ("TOP SECRET", "SECRET", "CONFIDENTIAL", "UNCLASSIFIED")
156
-
157
- for base-level in level-order {
158
- if base-level in level {
159
- return CLASSIFICATION_COLORS.at(base-level)
160
- }
161
- }
162
-
163
- rgb(0, 0, 0) // Default
164
- }
165
-
166
- // =============================================================================
167
- // GRID LAYOUT UTILITIES
168
- // =============================================================================
169
-
170
- /// Creates an automatic grid layout from string or array content.
171
- ///
172
- /// Converts 1D content into a multi-column grid layout with proper spacing.
173
- /// Used primarily for formatting recipient lists in the "MEMORANDUM FOR" section
174
- /// where multiple organizations need to be displayed in columns.
175
- ///
176
- /// Features:
177
- /// - Automatic column distribution and row filling
178
- /// - Configurable column spacing and count
179
- /// - Handles both single strings and arrays of strings
180
- /// - Adds padding cells to maintain consistent column alignment
181
- ///
182
- /// - content (str | array): Content to arrange in grid (strings only)
183
- /// - column-gutter (length): Space between columns (default: 0.5em)
184
- /// - cols (int): Number of columns for the grid (default: 3)
185
- /// -> grid
186
- #let create-auto-grid(content, column-gutter: .5em, cols: 3) = {
187
- let content_type = type(content)
188
-
189
- assert(content_type == str or content_type == array, message: "Content must be a string or an array of strings.")
190
- if content_type == array {
191
- for item in content {
192
- assert(type(item) == str, message: "All items in content array must be strings.")
193
- }
194
- }
195
-
196
- // Normalize to 1d array
197
- if content_type == str {
198
- content = (content,)
199
- }
200
-
201
-
202
- // Build cell array in row-major order
203
- let cells = ()
204
- let i = 0
205
- for item in content {
206
- i += 1
207
- cells.push(item)
208
- if calc.rem(i, cols) == 0 {
209
- // Add empty cell to pad the page
210
- cells.push([])
211
- }
212
- }
213
-
214
- // Add padding cells to complete the last row if needed
215
- let remainder = calc.rem(cells.len(), cols + 1)
216
- if remainder != 0 {
217
- let padding_needed = (cols + 1) - remainder
218
- for _ in range(padding_needed) {
219
- cells.push([])
220
- }
221
- }
222
-
223
- grid(
224
- columns: calc.max(1, cols) + 1,
225
- column-gutter: .1fr,
226
- row-gutter: spacing.line,
227
- ..cells
228
- )
229
- }
230
-
231
- // =============================================================================
232
- // TYPE NORMALIZATION UTILITIES
233
- // =============================================================================
234
-
235
- /// Ensures the input is an array. If already an array, returns as-is.
236
- /// If not an array, wraps the value in a tuple.
237
- ///
238
- /// This utility eliminates repetitive `if type(x) == array` checks throughout
239
- /// the codebase by providing a canonical "normalize to array" function.
240
- ///
241
- /// - value: Any value to normalize to array form
242
- /// - Returns: Array containing the value(s)
243
- ///
244
- /// Examples:
245
- /// - ensure-array("foo") → ("foo",)
246
- /// - ensure-array(("a", "b")) → ("a", "b")
247
- /// - ensure-array(none) → ()
248
- #let ensure-array(value) = {
249
- if value == none {
250
- ()
251
- } else if type(value) == array {
252
- value
253
- } else {
254
- (value,)
255
- }
256
- }
257
-
258
- /// Ensures the input is a string. If already a string, returns as-is.
259
- /// If an array, joins elements with the specified separator.
260
- ///
261
- /// This utility eliminates repetitive `if type(x) == array { x.join(...) }`
262
- /// checks throughout the codebase by providing a canonical "normalize to string"
263
- /// function.
264
- ///
265
- /// - value: Any value to normalize to string form
266
- /// - separator: String to use when joining array elements (default: "\n")
267
- /// - Returns: String representation of the value
268
- ///
269
- /// Examples:
270
- /// - ensure-string("foo") → "foo"
271
- /// - ensure-string(("a", "b")) → "a\nb"
272
- /// - ensure-string(("a", "b"), ", ") → "a, b"
273
- /// - ensure-string(none) → ""
274
- #let ensure-string(value, separator: "\n") = {
275
- if value == none {
276
- ""
277
- } else if type(value) == array {
278
- value.join(separator)
279
- } else {
280
- str(value)
281
- }
282
- }
283
-
284
- /// Extracts the first element from an array, or returns the value if not an array.
285
- ///
286
- /// This utility eliminates repetitive ternary operators like
287
- /// `if type(x) == array { x.at(0) } else { x }` by providing a canonical
288
- /// "first element or self" function.
289
- ///
290
- /// - value: Any value to extract from
291
- /// - Returns: First array element if array, otherwise the value itself
292
- ///
293
- /// Examples:
294
- /// - first-or-value("foo") → "foo"
295
- /// - first-or-value(("a", "b")) → "a"
296
- /// - first-or-value(()) → none
297
- /// - first-or-value(none) → none
298
- #let first-or-value(value) = {
299
- if value == none {
300
- none
301
- } else if type(value) == array {
302
- if value.len() > 0 {
303
- value.at(0)
304
- } else {
305
- none
306
- }
307
- } else {
308
- value
309
- }
310
- }
311
-
312
-
313
- // =============================================================================
314
- // INDORSEMENT UTILITIES
315
- // =============================================================================
316
-
317
- /// Converts number to ordinal suffix for indorsements following AFH 33-337 conventions.
318
- ///
319
- /// AFH 33-337 Chapter 14 indorsement examples show "1st Ind", "2d Ind", "3d Ind" format.
320
- /// Note: Military style uses "2d" and "3d" instead of "2nd" and "3rd" per DoD correspondence standards.
321
- ///
322
- /// Generates proper ordinal suffixes for indorsement numbering:
323
- /// - 1st, 2d, 3d, 4th, 5th, etc. (note: military uses "2d" and "3d", not "2nd" and "3rd")
324
- /// - Special handling for 11th, 12th, 13th (all use "th")
325
- /// - Follows official military correspondence standards
326
- ///
327
- /// - number (int): The indorsement number (1, 2, 3, etc.)
328
- /// -> str
329
- // NOTE: Could be table-driven vs algorithmic (see CASCADES.md §B) — current approach is correct
330
- #let get-ordinal-suffix(number) = {
331
- let last-digit = calc.rem(number, 10)
332
- let last-two-digits = calc.rem(number, 100)
333
-
334
- if last-two-digits >= 11 and last-two-digits <= 13 {
335
- "th"
336
- } else if last-digit == 1 {
337
- "st"
338
- } else if last-digit == 2 {
339
- "d"
340
- } else if last-digit == 3 {
341
- "d"
342
- } else {
343
- "th"
344
- }
345
- }
346
-
347
- /// Formats indorsement number according to AFH 33-337 standards.
348
- ///
349
- /// Creates properly formatted indorsement labels with ordinal suffixes:
350
- /// - "1st Ind", "2d Ind", "3d Ind", "4th Ind", etc.
351
- /// - Uses military-specific ordinal format (2d/3d instead of 2nd/3rd)
352
- /// - Combines with "Ind" suffix for standard indorsement header format
353
- ///
354
- /// - number (int): Indorsement sequence number (1, 2, 3, etc.)
355
- /// -> str
356
- #let format-indorsement-number(number) = {
357
- let suffix = get-ordinal-suffix(number)
358
- str(number) + suffix + " Ind"
359
- }
360
-
361
- /// Processes and renders an array of indorsements.
362
- ///
363
- /// Iterates through an array of indorsement objects and renders each one
364
- /// with proper formatting and font settings. Used by the main memorandum
365
- /// template to process the indorsements parameter.
366
- ///
367
- /// - indorsements (array): Array of indorsement objects created with indorsement()
368
- /// - body-font (str | array): Font(s) to use for indorsement text
369
- /// - font-size (length): Font size for indorsement text (default: 12pt)
370
- /// -> content
371
- #let process-indorsements(indorsements, body-font: none, font-size: 12pt) = {
372
- if not falsey(indorsements) {
373
- for indorsement in indorsements {
374
- (indorsement.render)(body-font: body-font, font-size: font-size)
375
- }
376
- }
377
- }
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
+ /// Creates vertical spacing equivalent to multiple blank lines.
18
+ ///
19
+ /// Calculates proper vertical space based on line height and leading
20
+ /// to maintain consistent spacing throughout the document.
21
+ ///
22
+ /// - count (int): Number of blank lines to create
23
+ /// - weak (bool): Whether spacing can be compressed at page breaks
24
+ /// -> content
25
+ #let blank-lines(count, weak: true) = {
26
+ if count == 0 {
27
+ v(0em, weak: weak)
28
+ } else {
29
+ let lead = spacing.line
30
+ let height = spacing.line-height
31
+ v(lead + (height + lead) * count, weak: weak)
32
+ }
33
+ }
34
+
35
+ /// Creates vertical spacing equivalent to one blank line.
36
+ /// Convenience function for single line spacing.
37
+ ///
38
+ /// - weak (bool): Whether spacing can be compressed at page breaks
39
+ /// -> content
40
+ #let blank-line(weak: true) = blank-lines(1, weak: weak)
41
+
42
+ // =============================================================================
43
+ // GENERAL UTILITY FUNCTIONS
44
+ // =============================================================================
45
+
46
+ /// Checks if a value is "falsey" (none, false, empty array, or empty string).
47
+ ///
48
+ /// Provides a consistent way to test for empty or missing values across
49
+ /// the template system. Used for conditional rendering of optional sections.
50
+ ///
51
+ /// - value (any): The value to check for "falsey" status
52
+ /// -> bool
53
+ #let falsey(value) = {
54
+ value == none or value == false or (type(value) == array and value.len() == 0) or (type(value) == str and value == "")
55
+ }
56
+
57
+ /// Scales content to fit within a specified box while maintaining aspect ratio.
58
+ ///
59
+ /// Automatically measures content and calculates uniform scaling to fit within
60
+ /// the given dimensions. Commonly used for letterhead seals and other images
61
+ /// that need to fit specific size constraints while preserving proportions.
62
+ ///
63
+ /// - width (length): Maximum width for the content (default: 2in)
64
+ /// - height (length): Maximum height for the content (default: 1in)
65
+ /// - alignment (alignment): Content alignment within the box (default: left+horizon)
66
+ /// - body (content): Content to scale and fit
67
+ /// -> content
68
+ #let fit-box(width: 2in, height: 1in, alignment: left + horizon, body) = context {
69
+ // 1) measure the unscaled content
70
+ let s = measure(body)
71
+
72
+ // 2) compute the uniform scale that fits inside the box
73
+ let f = calc.min(width / s.width, height / s.height) * 100% // ratio
74
+
75
+ // 3) fixed-size box, center the scaled content, and reflow so layout respects it
76
+ box(width: width, height: height, clip: true)[
77
+ #align(alignment)[
78
+ #scale(f, reflow: true)[#body]
79
+ ]
80
+ ]
81
+ }
82
+
83
+ /// Checks if a string is in ISO date format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS).
84
+ ///
85
+ /// Performs a simple pattern check for ISO 8601 date strings by verifying:
86
+ /// - String is at least 10 characters long
87
+ /// - Characters at positions 4 and 7 are dashes
88
+ ///
89
+ /// - date-str (str): String to check for ISO date pattern
90
+ /// -> bool
91
+ #let is-iso-date-string(date-str) = {
92
+ if date-str.len() == 10 {
93
+ let char4 = date-str.at(4)
94
+ let char7 = date-str.at(7)
95
+ return char4 == "-" and char7 == "-"
96
+ } else if date-str.len() > 10 {
97
+ let char4 = date-str.at(4)
98
+ let char7 = date-str.at(7)
99
+ let char10 = date-str.at(10)
100
+ return char4 == "-" and char7 == "-" and char10 == "T"
101
+ }
102
+ return false
103
+ }
104
+
105
+ /// Extracts the date portion (YYYY-MM-DD) from an ISO date string.
106
+ ///
107
+ /// Returns the first 10 characters which contain the date portion of an
108
+ /// ISO 8601 date string, removing any time component if present.
109
+ ///
110
+ /// - date-str (str): ISO date string to extract from
111
+ /// -> str
112
+ #let extract-iso-date(date-str) = {
113
+ date-str.slice(0, 10)
114
+ }
115
+
116
+ /// Formats a date in standard military format or ISO format depending on input type.
117
+ ///
118
+ /// AFH 33-337 "Date": "Use the 'Day Month Year' or 'DD Mmm YY' format for documents
119
+ /// addressed to a military organization. For civilian addressees, use the 'Month Day, Year' format."
120
+ /// Examples: "15 October 2014" or "15 Oct 14" for military, "October 15, 2014" for civilian
121
+ ///
122
+ /// Intelligently handles different date input formats:
123
+ /// - ISO string (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS): Parsed via TOML and displayed in military format
124
+ /// - Non-ISO string: Displayed as-is
125
+ /// - datetime object: Displayed in military format ("1 January 2024")
126
+ ///
127
+ /// - date (str|datetime): Date to format for display
128
+ /// -> str
129
+ // NOTE: Consider simplification if Typst adds native ISO date parsing (see CASCADES.md §A)
130
+ #let display-date(date) = {
131
+ if type(date) == str {
132
+ if is-iso-date-string(date) {
133
+ // Parse ISO date string using TOML to get datetime object
134
+ let iso-date = extract-iso-date(date)
135
+ let toml-str = "date = " + iso-date
136
+ let parsed = toml(bytes(toml-str))
137
+ parsed.date.display("[day padding:none] [month repr:long] [year]")
138
+ } else {
139
+ date
140
+ }
141
+ } else {
142
+ date.display("[day padding:none] [month repr:long] [year]")
143
+ }
144
+ }
145
+
146
+ /// Gets the color associated with a classification level.
147
+ ///
148
+ /// - level (str): Classification level string
149
+ /// -> color
150
+ #let get-classification-level-color(level) = {
151
+ if level == none {
152
+ return rgb(0, 0, 0) // Default to black if no classification
153
+ }
154
+ // Order matters - check most specific first
155
+ let level-order = ("TOP SECRET", "SECRET", "CONFIDENTIAL", "UNCLASSIFIED")
156
+
157
+ for base-level in level-order {
158
+ if base-level in level {
159
+ return CLASSIFICATION_COLORS.at(base-level)
160
+ }
161
+ }
162
+
163
+ rgb(0, 0, 0) // Default
164
+ }
165
+
166
+ // =============================================================================
167
+ // GRID LAYOUT UTILITIES
168
+ // =============================================================================
169
+
170
+ /// Creates an automatic grid layout from string or array content.
171
+ ///
172
+ /// Converts 1D content into a multi-column grid layout with proper spacing.
173
+ /// Used primarily for formatting recipient lists in the "MEMORANDUM FOR" section
174
+ /// where multiple organizations need to be displayed in columns.
175
+ ///
176
+ /// Features:
177
+ /// - Automatic column distribution and row filling
178
+ /// - Configurable column spacing and count
179
+ /// - Handles both single strings and arrays of strings
180
+ /// - Adds padding cells to maintain consistent column alignment
181
+ ///
182
+ /// - content (str | array): Content to arrange in grid (strings only)
183
+ /// - column-gutter (length): Space between columns (default: 0.5em)
184
+ /// - cols (int): Number of columns for the grid (default: 3)
185
+ /// -> grid
186
+ #let create-auto-grid(content, column-gutter: .5em, cols: 3) = {
187
+ let content_type = type(content)
188
+
189
+ assert(content_type == str or content_type == array, message: "Content must be a string or an array of strings.")
190
+ if content_type == array {
191
+ for item in content {
192
+ assert(type(item) == str, message: "All items in content array must be strings.")
193
+ }
194
+ }
195
+
196
+ // Normalize to 1d array
197
+ if content_type == str {
198
+ content = (content,)
199
+ }
200
+
201
+
202
+ // Build cell array in row-major order
203
+ let cells = ()
204
+ let i = 0
205
+ for item in content {
206
+ i += 1
207
+ cells.push(item)
208
+ if calc.rem(i, cols) == 0 {
209
+ // Add empty cell to pad the page
210
+ cells.push([])
211
+ }
212
+ }
213
+
214
+ // Add padding cells to complete the last row if needed
215
+ let remainder = calc.rem(cells.len(), cols + 1)
216
+ if remainder != 0 {
217
+ let padding_needed = (cols + 1) - remainder
218
+ for _ in range(padding_needed) {
219
+ cells.push([])
220
+ }
221
+ }
222
+
223
+ grid(
224
+ columns: calc.max(1, cols) + 1,
225
+ column-gutter: .1fr,
226
+ row-gutter: spacing.line,
227
+ ..cells
228
+ )
229
+ }
230
+
231
+ // =============================================================================
232
+ // TYPE NORMALIZATION UTILITIES
233
+ // =============================================================================
234
+
235
+ /// Ensures the input is an array. If already an array, returns as-is.
236
+ /// If not an array, wraps the value in a tuple.
237
+ ///
238
+ /// This utility eliminates repetitive `if type(x) == array` checks throughout
239
+ /// the codebase by providing a canonical "normalize to array" function.
240
+ ///
241
+ /// - value: Any value to normalize to array form
242
+ /// - Returns: Array containing the value(s)
243
+ ///
244
+ /// Examples:
245
+ /// - ensure-array("foo") → ("foo",)
246
+ /// - ensure-array(("a", "b")) → ("a", "b")
247
+ /// - ensure-array(none) → ()
248
+ #let ensure-array(value) = {
249
+ if value == none {
250
+ ()
251
+ } else if type(value) == array {
252
+ value
253
+ } else {
254
+ (value,)
255
+ }
256
+ }
257
+
258
+ /// Ensures the input is a string. If already a string, returns as-is.
259
+ /// If an array, joins elements with the specified separator.
260
+ ///
261
+ /// This utility eliminates repetitive `if type(x) == array { x.join(...) }`
262
+ /// checks throughout the codebase by providing a canonical "normalize to string"
263
+ /// function.
264
+ ///
265
+ /// - value: Any value to normalize to string form
266
+ /// - separator: String to use when joining array elements (default: "\n")
267
+ /// - Returns: String representation of the value
268
+ ///
269
+ /// Examples:
270
+ /// - ensure-string("foo") → "foo"
271
+ /// - ensure-string(("a", "b")) → "a\nb"
272
+ /// - ensure-string(("a", "b"), ", ") → "a, b"
273
+ /// - ensure-string(none) → ""
274
+ #let ensure-string(value, separator: "\n") = {
275
+ if value == none {
276
+ ""
277
+ } else if type(value) == array {
278
+ value.join(separator)
279
+ } else {
280
+ str(value)
281
+ }
282
+ }
283
+
284
+ /// Extracts the first element from an array, or returns the value if not an array.
285
+ ///
286
+ /// This utility eliminates repetitive ternary operators like
287
+ /// `if type(x) == array { x.at(0) } else { x }` by providing a canonical
288
+ /// "first element or self" function.
289
+ ///
290
+ /// - value: Any value to extract from
291
+ /// - Returns: First array element if array, otherwise the value itself
292
+ ///
293
+ /// Examples:
294
+ /// - first-or-value("foo") → "foo"
295
+ /// - first-or-value(("a", "b")) → "a"
296
+ /// - first-or-value(()) → none
297
+ /// - first-or-value(none) → none
298
+ #let first-or-value(value) = {
299
+ if value == none {
300
+ none
301
+ } else if type(value) == array {
302
+ if value.len() > 0 {
303
+ value.at(0)
304
+ } else {
305
+ none
306
+ }
307
+ } else {
308
+ value
309
+ }
310
+ }
311
+
312
+
313
+ // =============================================================================
314
+ // INDORSEMENT UTILITIES
315
+ // =============================================================================
316
+
317
+ /// Converts number to ordinal suffix for indorsements following AFH 33-337 conventions.
318
+ ///
319
+ /// AFH 33-337 Chapter 14 indorsement examples show "1st Ind", "2d Ind", "3d Ind" format.
320
+ /// Note: Military style uses "2d" and "3d" instead of "2nd" and "3rd" per DoD correspondence standards.
321
+ ///
322
+ /// Generates proper ordinal suffixes for indorsement numbering:
323
+ /// - 1st, 2d, 3d, 4th, 5th, etc. (note: military uses "2d" and "3d", not "2nd" and "3rd")
324
+ /// - Special handling for 11th, 12th, 13th (all use "th")
325
+ /// - Follows official military correspondence standards
326
+ ///
327
+ /// - number (int): The indorsement number (1, 2, 3, etc.)
328
+ /// -> str
329
+ // NOTE: Could be table-driven vs algorithmic (see CASCADES.md §B) — current approach is correct
330
+ #let get-ordinal-suffix(number) = {
331
+ let last-digit = calc.rem(number, 10)
332
+ let last-two-digits = calc.rem(number, 100)
333
+
334
+ if last-two-digits >= 11 and last-two-digits <= 13 {
335
+ "th"
336
+ } else if last-digit == 1 {
337
+ "st"
338
+ } else if last-digit == 2 {
339
+ "d"
340
+ } else if last-digit == 3 {
341
+ "d"
342
+ } else {
343
+ "th"
344
+ }
345
+ }
346
+
347
+ /// Formats indorsement number according to AFH 33-337 standards.
348
+ ///
349
+ /// Creates properly formatted indorsement labels with ordinal suffixes:
350
+ /// - "1st Ind", "2d Ind", "3d Ind", "4th Ind", etc.
351
+ /// - Uses military-specific ordinal format (2d/3d instead of 2nd/3rd)
352
+ /// - Combines with "Ind" suffix for standard indorsement header format
353
+ ///
354
+ /// - number (int): Indorsement sequence number (1, 2, 3, etc.)
355
+ /// -> str
356
+ #let format-indorsement-number(number) = {
357
+ let suffix = get-ordinal-suffix(number)
358
+ str(number) + suffix + " Ind"
359
+ }
360
+
361
+ /// Processes and renders an array of indorsements.
362
+ ///
363
+ /// Iterates through an array of indorsement objects and renders each one
364
+ /// with proper formatting and font settings. Used by the main memorandum
365
+ /// template to process the indorsements parameter.
366
+ ///
367
+ /// - indorsements (array): Array of indorsement objects created with indorsement()
368
+ /// - body-font (str | array): Font(s) to use for indorsement text
369
+ /// - font-size (length): Font size for indorsement text (default: 12pt)
370
+ /// -> content
371
+ #let process-indorsements(indorsements, body-font: none, font-size: 12pt) = {
372
+ if not falsey(indorsements) {
373
+ for indorsement in indorsements {
374
+ (indorsement.render)(body-font: body-font, font-size: font-size)
375
+ }
376
+ }
377
+ }