@tonguetoquill/collection 0.1.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.
Files changed (102) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +39 -0
  3. package/index.d.ts +2 -0
  4. package/index.js +8 -0
  5. package/package.json +36 -0
  6. package/quills/classic_resume/0.1.0/Quill.yaml +118 -0
  7. package/quills/classic_resume/0.1.0/example.md +232 -0
  8. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/LICENSE +21 -0
  9. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/README.md +38 -0
  10. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/fonts/EBGaramond-Bold.ttf +0 -0
  11. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/fonts/EBGaramond-BoldItalic.ttf +0 -0
  12. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/fonts/EBGaramond-Italic.ttf +0 -0
  13. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/fonts/EBGaramond-Regular.ttf +0 -0
  14. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/src/components.typ +184 -0
  15. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/src/layout.typ +42 -0
  16. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/src/lib.typ +5 -0
  17. package/quills/classic_resume/0.1.0/packages/ttq-classic-resume/typst.toml +26 -0
  18. package/quills/classic_resume/0.1.0/plate.typ +44 -0
  19. package/quills/cmu_letter/0.1.0/.quillignore +31 -0
  20. package/quills/cmu_letter/0.1.0/Quill.yaml +64 -0
  21. package/quills/cmu_letter/0.1.0/assets/cmu-wordmark.svg +174 -0
  22. package/quills/cmu_letter/0.1.0/example.md +31 -0
  23. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/LICENSE +21 -0
  24. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/fonts/OFL.txt +93 -0
  25. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/fonts/OpenSans-Bold.ttf +0 -0
  26. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/fonts/OpenSans-BoldItalic.ttf +0 -0
  27. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/fonts/OpenSans-Italic.ttf +0 -0
  28. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/fonts/OpenSans-Regular.ttf +0 -0
  29. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/fonts/README.txt +100 -0
  30. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/backmatter.typ +13 -0
  31. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/config.typ +40 -0
  32. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/frontmatter.typ +72 -0
  33. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/lib.typ +47 -0
  34. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/mainmatter.typ +42 -0
  35. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/primitives.typ +70 -0
  36. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/src/utils.typ +85 -0
  37. package/quills/cmu_letter/0.1.0/packages/tonguetoquill-cmu-letter/typst.toml +17 -0
  38. package/quills/cmu_letter/0.1.0/plate.typ +19 -0
  39. package/quills/taro/0.1.0/Quill.yaml +29 -0
  40. package/quills/taro/0.1.0/assets/Figtree-Bold.ttf +0 -0
  41. package/quills/taro/0.1.0/assets/Figtree-Italic.ttf +0 -0
  42. package/quills/taro/0.1.0/assets/Figtree-Regular.ttf +0 -0
  43. package/quills/taro/0.1.0/example.md +27 -0
  44. package/quills/taro/0.1.0/plate.typ +31 -0
  45. package/quills/usaf_memo/0.1.0/.quillignore +31 -0
  46. package/quills/usaf_memo/0.1.0/Quill.yaml +209 -0
  47. package/quills/usaf_memo/0.1.0/assets/dow_seal.jpg +0 -0
  48. package/quills/usaf_memo/0.1.0/example.md +55 -0
  49. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/LICENSE +21 -0
  50. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel-Regular.ttf +0 -0
  51. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE +93 -0
  52. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
  53. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/LICENSE.md +79 -0
  54. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
  55. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
  56. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
  57. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
  58. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
  59. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +28 -0
  60. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/body.typ +325 -0
  61. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/config.typ +63 -0
  62. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +114 -0
  63. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +118 -0
  64. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/lib.typ +55 -0
  65. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +32 -0
  66. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +272 -0
  67. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/src/utils.typ +377 -0
  68. package/quills/usaf_memo/0.1.0/packages/tonguetoquill-usaf-memo/typst.toml +17 -0
  69. package/quills/usaf_memo/0.1.0/plate.typ +74 -0
  70. package/quills/usaf_memo/0.2.0/.quillignore +31 -0
  71. package/quills/usaf_memo/0.2.0/Quill.yaml +225 -0
  72. package/quills/usaf_memo/0.2.0/assets/dow_seal.jpg +0 -0
  73. package/quills/usaf_memo/0.2.0/example.md +57 -0
  74. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/LICENSE +21 -0
  75. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel-Regular.ttf +0 -0
  76. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE +93 -0
  77. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
  78. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/LICENSE.md +79 -0
  79. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
  80. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
  81. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
  82. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
  83. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
  84. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +28 -0
  85. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/body.typ +325 -0
  86. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/config.typ +63 -0
  87. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +114 -0
  88. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +118 -0
  89. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/lib.typ +55 -0
  90. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +32 -0
  91. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +272 -0
  92. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/utils.typ +377 -0
  93. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/typst.toml +17 -0
  94. package/quills/usaf_memo/0.2.0/plate.typ +76 -0
  95. package/templates/cmu_letter_template.md +38 -0
  96. package/templates/loc.md +79 -0
  97. package/templates/pass_request.md +44 -0
  98. package/templates/rebuttal.md +56 -0
  99. package/templates/taro.md +27 -0
  100. package/templates/templates.json +44 -0
  101. package/templates/usaf_template.md +23 -0
  102. package/templates/ussf_template.md +29 -0
@@ -0,0 +1,32 @@
1
+ // mainmatter.typ: Mainmatter show rule for USAF memorandum
2
+ //
3
+ // This module implements the mainmatter (body text) of a USAF memorandum per
4
+ // AFH 33-337 Chapter 14 "The Text of the Official Memorandum" (§1-12).
5
+
6
+ #import "primitives.typ": *
7
+ #import "body.typ": *
8
+
9
+ /// Mainmatter show rule for USAF memorandum body content.
10
+ ///
11
+ /// AFH 33-337 "The Text of the Official Memorandum" §1-12 requirements:
12
+ /// - Begin text on second line below subject/references
13
+ /// - Single-space text, double-space between paragraphs
14
+ /// - Number and letter each paragraph/subparagraph
15
+ /// - "A single paragraph is not numbered" (§2)
16
+ /// - First paragraph flush left, never indented
17
+ ///
18
+ /// Applies AFH 33-337 paragraph numbering and formatting to the main body
19
+ /// of the memorandum. Automatically detects single vs. multiple paragraphs
20
+ /// to comply with AFH 33-337 numbering requirements.
21
+ ///
22
+ /// When auto_numbering is false (set in frontmatter), base-level paragraphs
23
+ /// render flush left without numbering. Only explicitly numbered or bulleted
24
+ /// items enter the numbering hierarchy.
25
+ ///
26
+ /// - content (content): The body content to render
27
+ /// -> content
28
+ #let mainmatter(it) = context {
29
+ let config = query(metadata).last().value
30
+ let auto-numbering = config.at("auto_numbering", default: true)
31
+ render-body(it, auto-numbering: auto-numbering)
32
+ }
@@ -0,0 +1,272 @@
1
+ // primitives.typ: Reusable rendering primitives for USAF memorandum sections
2
+ //
3
+ // This module implements the visual rendering functions that produce AFH 33-337
4
+ // compliant formatting for all sections of a USAF memorandum. Each function
5
+ // corresponds to specific placement and formatting requirements from Chapter 14.
6
+
7
+ #import "config.typ": *
8
+ #import "utils.typ": *
9
+
10
+ // =============================================================================
11
+ // LETTERHEAD RENDERING
12
+ // =============================================================================
13
+ // AFH 33-337 §1: "Use printed letterhead, computer-generated letterhead, or plain bond paper"
14
+ // Letterhead placement is not explicitly specified in AFH 33-337, but follows
15
+ // standard USAF memo formatting conventions
16
+
17
+ #let render-letterhead(title, caption, letterhead-seal, font) = {
18
+ font = ensure-array(font)
19
+ caption = ensure-string(caption)
20
+
21
+ place(
22
+ dy: 0.625in - spacing.margin,
23
+ box(
24
+ width: 100%,
25
+ fill: none,
26
+ stroke: none,
27
+ [
28
+ #place(
29
+ center + top,
30
+ align(center)[
31
+ #set text(12pt, font: font, fill: LETTERHEAD_COLOR)
32
+ #title\
33
+ #text(10.5pt)[#caption]
34
+ ],
35
+ )
36
+ ],
37
+ ),
38
+ )
39
+
40
+ if letterhead-seal != none {
41
+ place(
42
+ left + top,
43
+ dx: -0.5in,
44
+ dy: -.5in,
45
+ block[
46
+ #fit-box(width: 2in, height: 1in)[#letterhead-seal]
47
+ ],
48
+ )
49
+ }
50
+ }
51
+
52
+ // =============================================================================
53
+ // HEADER SECTIONS
54
+ // =============================================================================
55
+ // AFH 33-337 "The Heading Section" specifies exact placement and format for:
56
+ // - Date: 1 inch from right edge, 1.75 inches from top
57
+ // - MEMORANDUM FOR: Second line below date
58
+ // - FROM: Second line below MEMORANDUM FOR
59
+ // - SUBJECT: Second line below FROM
60
+
61
+ // AFH 33-337 "Date": "Place the date 1 inch from the right edge, 1.75 inches from the top"
62
+ #let render-date-section(date) = {
63
+ align(right)[#display-date(date)]
64
+ }
65
+
66
+ // AFH 33-337 "MEMORANDUM FOR": "Place 'MEMORANDUM FOR' on the second line below the date"
67
+ #let render-for-section(recipients, cols) = {
68
+ blank-line()
69
+ grid(
70
+ columns: (auto, auto, 1fr),
71
+ "MEMORANDUM FOR",
72
+ " ",
73
+ align(left)[
74
+ #if type(recipients) == array {
75
+ create-auto-grid(recipients, column-gutter: spacing.tab, cols: cols)
76
+ } else {
77
+ recipients
78
+ }
79
+ ],
80
+ )
81
+ }
82
+
83
+ // AFH 33-337 "FROM:": "Place 'FROM:' in uppercase, flush with the left margin,
84
+ // on the second line below the last line of the MEMORANDUM FOR element"
85
+ #let render-from-section(from-info) = {
86
+ blank-line()
87
+ from-info = ensure-string(from-info)
88
+
89
+ grid(
90
+ columns: (auto, auto, 1fr),
91
+ "FROM:", " ", align(left)[#from-info],
92
+ )
93
+ }
94
+
95
+ // AFH 33-337 "SUBJECT:": "In all uppercase letters place 'SUBJECT:', flush with the
96
+ // left margin, on the second line below the last line of the FROM element"
97
+ #let render-subject-section(subject-text) = {
98
+ blank-line()
99
+ grid(
100
+ columns: (auto, auto, 1fr),
101
+ "SUBJECT:", " ", [#subject-text],
102
+ )
103
+ }
104
+
105
+ #let render-references-section(references) = {
106
+ if not falsey(references) {
107
+ blank-line()
108
+ grid(
109
+ columns: (auto, auto, 1fr),
110
+ "References:", " ", enum(..references, numbering: "(a)"),
111
+ )
112
+ }
113
+ }
114
+
115
+ // =============================================================================
116
+ // SIGNATURE BLOCK
117
+ // =============================================================================
118
+ // AFH 33-337 "Signature Block": "Start the signature block on the fifth line below
119
+ // the last line of text and 4.5 inches from the left edge of the page"
120
+ // AFH 33-337 "Do not place the signature element on a continuation page by itself"
121
+
122
+ #let render-signature-block(signature-lines, signature-blank-lines: 4) = {
123
+ signature-lines = ensure-array(signature-lines)
124
+ // AFH 33-337: "The signature block is never on a page by itself"
125
+ // Note: Perfect enforcement isn't feasible without over-engineering
126
+ // We use weak: false spacing and breakable: false to discourage orphaning
127
+ // AFH 33-337: "fifth line below" = 4 blank lines between text and signature block
128
+ blank-lines(signature-blank-lines, weak: false)
129
+ block(breakable: false)[
130
+ #align(left)[
131
+ // AFH 33-337: "4.5 inches from the left edge of the page"
132
+ // We use (4.5in - margin) because Typst's pad() is relative to the text area, not page edge
133
+ #pad(left: 4.5in - spacing.margin)[
134
+ #text(hyphenate: false)[
135
+ #for line in signature-lines {
136
+ par(hanging-indent: 4 * 0.5em, line)
137
+ }
138
+ ]
139
+ ]
140
+ ]
141
+ ]
142
+ }
143
+
144
+ // =============================================================================
145
+ // ACTION LINE RENDERING
146
+ // =============================================================================
147
+ // Renders the APPROVED / DISAPPROVED action line for indorsement memos.
148
+ // action: none = both plain (no decision yet), "approved" = bold APPROVED /
149
+ // strike DISAPPROVED, "disapproved" = strike APPROVED / bold DISAPPROVED.
150
+ // Visibility is controlled by the caller (show_action / action != none).
151
+
152
+ #let render-action-line(action) = {
153
+ assert(
154
+ action in (none, "approved", "disapproved"),
155
+ message: "action must be none, \"approved\", or \"disapproved\"",
156
+ )
157
+ blank-line()
158
+ let approved-text = if action == "approved" { strong[APPROVED] } else if action == "disapproved" { strike[APPROVED] } else { [APPROVED] }
159
+ let disapproved-text = if action == "disapproved" { strong[DISAPPROVED] } else if action == "approved" { strike[DISAPPROVED] } else { [DISAPPROVED] }
160
+ [#approved-text / #disapproved-text]
161
+ }
162
+
163
+ // =============================================================================
164
+ // TABLE RENDERING
165
+ // =============================================================================
166
+ // AFH 33-337 does not specify table formatting, so we follow the general
167
+ // aesthetic principles of the standard: plain black borders, no decorative
168
+ // fills, and the body font inherited throughout.
169
+
170
+ /// Renders a table with USAF memorandum–consistent formatting.
171
+ ///
172
+ /// Applies simple 0.5pt black cell borders and standard padding to any
173
+ /// Typst `table` element, keeping the visual style clean and formal.
174
+ /// Font and size are inherited from the surrounding body text.
175
+ ///
176
+ /// - it (content): The table element to style and render
177
+ /// -> content
178
+ #let render-memo-table(it) = {
179
+ set table(
180
+ stroke: 0.5pt + black,
181
+ inset: (x: 0.5em, y: 0.4em),
182
+ )
183
+ it
184
+ }
185
+
186
+ // =============================================================================
187
+ // BACKMATTER SECTIONS
188
+ // =============================================================================
189
+ // AFH 33-337 "Attachment or Attachments": "Place 'Attachment:' (for a single attachment)
190
+ // or '# Attachments:' (for two or more attachments) at the left margin, on the third
191
+ // line below the signature element"
192
+ // AFH 33-337 "Courtesy Copy Element": "place 'cc:' flush with the left margin, on the
193
+ // second line below the attachment element"
194
+
195
+ #let render-backmatter-section(
196
+ content,
197
+ section-label,
198
+ numbering-style: none,
199
+ continuation-label: none,
200
+ ) = {
201
+ let formatted-content = {
202
+ // Use text() wrapper to prevent section label from being treated as a paragraph
203
+ text()[#section-label]
204
+ linebreak()
205
+ if numbering-style != none {
206
+ let items = ensure-array(content)
207
+ enum(..items, numbering: numbering-style)
208
+ } else {
209
+ ensure-string(content)
210
+ }
211
+ }
212
+
213
+ context {
214
+ let available-space = page.height - here().position().y - 1in
215
+ if measure(formatted-content).height > available-space {
216
+ let continuation-text = if continuation-label != none {
217
+ text()[#continuation-label]
218
+ } else {
219
+ text()[#section-label + " (listed on next page):"]
220
+ }
221
+ continuation-text
222
+ pagebreak()
223
+ }
224
+ formatted-content
225
+ }
226
+ }
227
+
228
+ #let calculate-backmatter-spacing(is-first-section) = {
229
+ context {
230
+ let line_count = if is-first-section { 2 } else { 1 }
231
+ blank-lines(line_count)
232
+ }
233
+ }
234
+
235
+ #let render-backmatter-sections(
236
+ attachments: none,
237
+ cc: none,
238
+ distribution: none,
239
+ leading-pagebreak: false,
240
+ ) = {
241
+ let has-backmatter = (
242
+ (attachments != none and attachments.len() > 0)
243
+ or (cc != none and cc.len() > 0)
244
+ or (distribution != none and distribution.len() > 0)
245
+ )
246
+
247
+ if leading-pagebreak and has-backmatter {
248
+ pagebreak(weak: true)
249
+ }
250
+
251
+ if attachments != none and attachments.len() > 0 {
252
+ calculate-backmatter-spacing(true)
253
+ let attachment-count = attachments.len()
254
+ let section-label = if attachment-count == 1 { "Attachment:" } else { str(attachment-count) + " Attachments:" }
255
+ let continuation-label = (
256
+ (if attachment-count == 1 { "Attachment" } else { str(attachment-count) + " Attachments" })
257
+ + " (listed on next page):"
258
+ )
259
+ render-backmatter-section(attachments, section-label, numbering-style: "1.", continuation-label: continuation-label)
260
+ }
261
+
262
+ if cc != none and cc.len() > 0 {
263
+ calculate-backmatter-spacing(attachments == none or attachments.len() == 0)
264
+ render-backmatter-section(cc, "cc:")
265
+ }
266
+
267
+ if distribution != none and distribution.len() > 0 {
268
+ calculate-backmatter-spacing((attachments == none or attachments.len() == 0) and (cc == none or cc.len() == 0))
269
+ render-backmatter-section(distribution, "DISTRIBUTION:")
270
+ }
271
+ }
272
+
@@ -0,0 +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
+ }