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