@airmark/quiver 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +82 -33
  2. package/package.json +7 -5
  3. package/quills/af4141/0.1.0/Quill.yaml +21 -11
  4. package/quills/af4141/0.1.0/packages/typst-af4141/lib.typ +17 -3
  5. package/quills/af4141/0.1.0/plate.typ +11 -4
  6. package/quills/daf1206/0.1.0/Quill.yaml +3 -1
  7. package/quills/daf4392/0.1.0/Quill.yaml +14 -11
  8. package/quills/daf4392/0.1.0/packages/daf4392page2_pkg/lib.typ +21 -4
  9. package/quills/usaf_memo/0.2.0/Quill.yaml +60 -44
  10. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/body.typ +37 -15
  11. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +5 -6
  12. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +5 -3
  13. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +87 -35
  14. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/utils.typ +47 -5
  15. package/quills/usaf_memo/0.2.0/plate.typ +15 -3
  16. package/quills/usaf_memo/0.3.0/Quill.yaml +321 -0
  17. package/quills/usaf_memo/0.3.0/assets/dod_seal.png +0 -0
  18. package/quills/usaf_memo/0.3.0/assets/dow_seal.png +0 -0
  19. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/.gitignore +7 -0
  20. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/LICENSE +21 -0
  21. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel[wght].ttf +0 -0
  22. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE +93 -0
  23. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Bold.otf +0 -0
  24. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
  25. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/LICENSE.md +79 -0
  26. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/LiberationMono/LICENSE +102 -0
  27. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/LiberationMono/LiberationMono-Regular.ttf +0 -0
  28. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
  29. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
  30. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
  31. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
  32. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
  33. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +29 -0
  34. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +364 -0
  35. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/config.typ +78 -0
  36. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +242 -0
  37. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +170 -0
  38. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +57 -0
  39. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +27 -0
  40. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +462 -0
  41. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +419 -0
  42. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/typst.toml +27 -0
  43. package/quills/usaf_memo/0.3.0/plate.typ +120 -0
  44. package/quills/af4141/0.1.0/design/TASK.md +0 -20
  45. package/quills/af4141/0.1.0/design/af4141.pdf +0 -0
  46. package/quills/daf1206/0.1.0/DAFForm_1206_prepared.pdf +0 -0
  47. package/quills/usaf_memo/0.2.0/.quillignore +0 -31
@@ -2,10 +2,14 @@ quill:
2
2
  name: usaf_memo
3
3
  version: 0.2.0
4
4
  backend: typst
5
- plate_file: plate.typ
6
5
  description: U.S. Air Force and Space Force Official Memorandum
7
6
 
7
+ typst:
8
+ plate_file: plate.typ
9
+
8
10
  main:
11
+ ui:
12
+ groups: [addressing, letterhead, classification, additional]
9
13
  body:
10
14
  example: |
11
15
  The first paragraph. Top-level paragraphs are auto-numbered; do not add manual numbering.
@@ -20,7 +24,8 @@ main:
20
24
  - ORG1/SYMBOL
21
25
  - ORG2/SYMBOL
22
26
  ui:
23
- group: Addressing
27
+ group: addressing
28
+ compact: true
24
29
  description: "Organization/office symbol in UPPERCASE. To address a specific person, add their rank and name in parentheses (e.g., 'ORG/SYMBOL (LT COL JANE DOE)'). For many recipients, enter 'SEE DISTRIBUTION' and list them in the Distribution field."
25
30
 
26
31
  memo_from:
@@ -34,14 +39,15 @@ main:
34
39
  - 123 Street Ave
35
40
  - City ST 12345-6789
36
41
  ui:
37
- group: Addressing
42
+ group: addressing
43
+ compact: true
38
44
  description: "If recipients are on the same installation, use only the office symbol. For recipients on other installations, include the full mailing address to enable return correspondence. Omit entirely to produce a Memorandum for Record (no MEMO FROM line)."
39
45
 
40
46
  subject:
41
47
  type: string
42
48
  example: Subject of the Memorandum
43
49
  ui:
44
- group: Addressing
50
+ group: addressing
45
51
  description: Be brief and clear. Capitalize the first letter of each word except articles, prepositions, and conjunctions. Include suspense dates in parentheses if applicable.
46
52
 
47
53
  signature_block:
@@ -52,14 +58,15 @@ main:
52
58
  - "FIRST M. LAST, Rank, USAF"
53
59
  - Duty Title
54
60
  ui:
55
- group: Addressing
61
+ group: addressing
62
+ compact: true
56
63
  description: "Line 1: Name in UPPERCASE as signed, grade, and service. Line 2: Duty title."
57
64
 
58
65
  letterhead_title:
59
66
  type: string
60
67
  default: DEPARTMENT OF THE AIR FORCE
61
68
  ui:
62
- group: Letterhead
69
+ group: letterhead
63
70
  description: Change only for Joint Commands or DoW agencies.
64
71
 
65
72
  letterhead_caption:
@@ -69,17 +76,17 @@ main:
69
76
  example:
70
77
  - HEADQUARTERS [UNIT NAME]
71
78
  ui:
72
- group: Letterhead
79
+ group: letterhead
73
80
  description: The full organization name of your unit.
74
81
 
75
82
  letterhead_seal:
76
- type: string
77
- enum:
83
+ type: enum
84
+ values:
78
85
  - dow
79
86
  - dod
80
87
  default: dow
81
88
  ui:
82
- group: Letterhead
89
+ group: letterhead
83
90
  compact: true
84
91
  description: Department of War (DoW) or Department of Defense (DoD) seal shown in the letterhead.
85
92
 
@@ -87,21 +94,22 @@ main:
87
94
  type: string
88
95
  default: ""
89
96
  ui:
90
- group: Letterhead
97
+ group: letterhead
91
98
  compact: true
92
99
  description: Bold-caps line below the seal; blank to omit.
93
100
 
94
101
  tag_line:
95
- type: markdown
102
+ type: richtext
103
+ inline: true
96
104
  default: ""
97
105
  ui:
98
- group: Letterhead
106
+ group: letterhead
99
107
  compact: true
100
108
  description: "Organizational motto at the bottom of the page. Supports Markdown emphasis (e.g. *italics*, **bold**)."
101
109
 
102
110
  classification:
103
- type: string
104
- enum:
111
+ type: enum
112
+ values:
105
113
  - ""
106
114
  - UNCLASSIFIED
107
115
  - CUI
@@ -110,7 +118,7 @@ main:
110
118
  - TOP SECRET
111
119
  default: ""
112
120
  ui:
113
- group: Classification
121
+ group: classification
114
122
  compact: true
115
123
  description: "Select the classification marking shown in the header and footer banner. Unclassified documents typically omit this banner entirely — leave blank unless the document is CUI or classified. Mark per DAFI 16-1404 (DoDM 5200.48 for CUI) and applicable DoD guidance."
116
124
 
@@ -118,7 +126,7 @@ main:
118
126
  type: string
119
127
  default: ""
120
128
  ui:
121
- group: Classification
129
+ group: classification
122
130
  compact: true
123
131
  description: "Banner dissemination suffix appended after double slash (e.g. 'NF' renders as CUI//NF in the header/footer). Leave blank for no suffix."
124
132
 
@@ -126,7 +134,7 @@ main:
126
134
  type: string
127
135
  default: ""
128
136
  ui:
129
- group: Classification
137
+ group: classification
130
138
  compact: true
131
139
  description: "Office or organization that designated this information as CUI (e.g., 'SAF/AA'). Required by DoDM 5200.48 when classification is CUI."
132
140
 
@@ -134,7 +142,7 @@ main:
134
142
  type: string
135
143
  default: ""
136
144
  ui:
137
- group: Classification
145
+ group: classification
138
146
  compact: true
139
147
  description: "CUI category from the DoD CUI Registry (e.g., 'Privacy/MED', 'CTI', 'PRVCY'). Leave blank to omit."
140
148
 
@@ -142,7 +150,7 @@ main:
142
150
  type: string
143
151
  default: ""
144
152
  ui:
145
- group: Classification
153
+ group: classification
146
154
  compact: true
147
155
  description: "Limited dissemination control shown in the indicator block (e.g., 'FEDONLY', 'NOFORN'). Independent from the banner suffix above."
148
156
 
@@ -150,20 +158,21 @@ main:
150
158
  type: string
151
159
  default: ""
152
160
  ui:
153
- group: Classification
161
+ group: classification
154
162
  compact: true
155
163
  description: "Point of contact for CUI questions (e.g., 'Capt J. Smith, DSN 555-1234, john.smith@us.af.mil'). Required by DoDM 5200.48 when classification is CUI."
156
164
 
157
165
  references:
158
166
  type: array
159
167
  items:
160
- type: markdown
168
+ type: richtext
169
+ inline: true
161
170
  default: []
162
171
  example:
163
172
  - "AFMAN 33-326, 25 November 2011, *Preparing Official Communications*"
164
173
  - "AFI 33-360, *Publications and Forms Management*"
165
174
  ui:
166
- group: Additional
175
+ group: additional
167
176
  description: "References, auto-lettered (a), (b), (c) per AFH 33-337. Cite each by organization, type, date, and title; italicize publication titles with *asterisks* (standard Markdown emphasis)."
168
177
 
169
178
  cc:
@@ -174,7 +183,7 @@ main:
174
183
  example:
175
184
  - Rank and Name, ORG/SYMBOL
176
185
  ui:
177
- group: Additional
186
+ group: additional
178
187
  description: List office symbols of recipients to receive copies.
179
188
 
180
189
  distribution:
@@ -186,7 +195,7 @@ main:
186
195
  - ORG1/SYMBOL
187
196
  - ORG2/SYMBOL
188
197
  ui:
189
- group: Additional
198
+ group: additional
190
199
  description: Complete list of recipients if 'SEE DISTRIBUTION' is used in the 'Memo For' field.
191
200
 
192
201
  attachments:
@@ -197,17 +206,17 @@ main:
197
206
  example:
198
207
  - Attachment description, YYYY MMM DD
199
208
  ui:
200
- group: Additional
209
+ group: additional
201
210
  description: List attachments in the order they are mentioned in the memo. Briefly describe each; do not use 'as stated' or abbreviations.
202
211
 
203
212
  memo_style:
204
- type: string
205
- enum:
213
+ type: enum
214
+ values:
206
215
  - usaf
207
216
  - daf
208
217
  default: usaf
209
218
  ui:
210
- group: Additional
219
+ group: additional
211
220
  compact: true
212
221
  description: "USAF memorandum or DAF headquarters memorandum; DAF changes date format and body paragraph layout per AFH 33-337."
213
222
 
@@ -215,15 +224,15 @@ main:
215
224
  type: number
216
225
  default: 12
217
226
  ui:
218
- group: Additional
227
+ group: additional
219
228
  compact: true
220
229
  description: Body text size in points; decimals allowed (e.g. 11.5).
221
230
 
222
231
  date:
223
- type: datetime
232
+ type: date
224
233
  default: ""
225
234
  ui:
226
- group: Additional
235
+ group: additional
227
236
  compact: true
228
237
  description: YYYY-MM-DD. Leave blank to use today's date.
229
238
 
@@ -232,18 +241,21 @@ card_kinds:
232
241
  description: Chain of routing endorsements. Each endorsement block adds an official response or forwarding action to the original memo.
233
242
  ui:
234
243
  title: Routing indorsement
244
+ groups: [addressing, additional]
235
245
  fields:
236
246
  from:
237
247
  type: string
238
248
  example: ORG/SYMBOL
239
249
  ui:
240
- group: Addressing
250
+ group: addressing
251
+ compact: true
241
252
  description: Office symbol or Rank Name of the endorsing official.
242
253
  for:
243
254
  type: string
244
255
  example: ORG/SYMBOL
245
256
  ui:
246
- group: Addressing
257
+ group: addressing
258
+ compact: true
247
259
  description: Office symbol or organization receiving the endorsed memo.
248
260
  signature_block:
249
261
  type: array
@@ -253,35 +265,39 @@ card_kinds:
253
265
  - "FIRST M. LAST, Rank, USAF"
254
266
  - Duty Title
255
267
  ui:
256
- group: Addressing
268
+ group: addressing
269
+ compact: true
257
270
  description: "Line 1: Name in UPPERCASE as signed, grade, and service. Line 2: Duty title."
258
271
  format:
259
- type: string
260
- enum:
272
+ type: enum
273
+ values:
261
274
  - standard
262
275
  - informal
263
276
  - separate_page
264
277
  default: standard
265
278
  ui:
266
- group: Additional
279
+ group: additional
267
280
  compact: true
268
281
  description: "'standard' (formal, same page), 'informal' (less formal routing), or 'separate_page' (begins on a new page)."
269
282
  action:
270
- type: string
271
- enum:
283
+ type: enum
284
+ values:
272
285
  - ""
273
286
  - undecided
274
287
  - approve
275
288
  - disapprove
289
+ - undecided_concur
290
+ - concur
291
+ - nonconcur
276
292
  default: ""
277
293
  ui:
278
- group: Additional
294
+ group: additional
279
295
  compact: true
280
- description: "Action taken by the endorser. Leave blank to hide the Approve/Disapprove line entirely, 'undecided' to display the line with neither option circled, 'approve' or 'disapprove' to circle the selected option."
296
+ description: "Decision taken by the endorser, shown as an either/or line with the choice circled. Leave blank to hide the line entirely. Approval authority: 'approve' or 'disapprove' (use 'undecided' to print Approve/Disapprove for the endorser to circle by hand). Coordinating official: 'concur' or 'nonconcur' (use 'undecided_concur' to print Concur/Nonconcur unmarked)."
281
297
  date:
282
- type: datetime
298
+ type: date
283
299
  default: ""
284
300
  ui:
285
- group: Additional
301
+ group: additional
286
302
  compact: true
287
303
  description: "YYYY-MM-DD. Date the endorser signs/forwards the memo (per AFH 33-337 Ch. 14, this is distinct from the originating memo's date). Leave blank to render a fill-in line for the endorser to date by hand when signing."
@@ -199,6 +199,7 @@
199
199
  // item.kind — "par", "heading", "table", or "continuation"
200
200
  context {
201
201
  let heading_buffer = none
202
+ let heading_level = 0
202
203
  // Filter out zero-width paragraphs so that an empty body (e.g. empty
203
204
  // string from a KIND with no text) emits nothing and collapses to zero
204
205
  // vertical space. Tables are always kept regardless of measured width.
@@ -226,24 +227,36 @@
226
227
  let kind = item.kind
227
228
  let item_content = item.content
228
229
 
229
- // Buffer headings for prepend to the next rendered element
230
- if kind == "heading" {
231
- heading_buffer = item_content
232
- continue
233
- }
234
-
235
- // Prepend buffered heading to the next non-heading element
230
+ // A buffered heading runs into this element only when the two belong to
231
+ // the same item: a later block of this list item ("continuation"), or,
232
+ // at top level, the next paragraph. The first block of the *next* item,
233
+ // a table (nest_level −1, so the level test alone excludes it), and
234
+ // another heading all fail the test, and each would otherwise carry the
235
+ // heading's text somewhere it was not authored. Those emit the heading
236
+ // on its own line, the treatment a heading before a table takes.
236
237
  if heading_buffer != none {
237
- if kind == "table" {
238
- // Tables cannot have inline text prepended; emit heading as
239
- // a standalone bold line above the table
240
- blank-line()
241
- strong[#heading_buffer.]
242
- heading_buffer = none
243
- } else {
238
+ let runs_in = (
239
+ kind != "heading"
240
+ and item.nest_level == heading_level
241
+ and (heading_level == 0 or kind == "continuation")
242
+ )
243
+ if runs_in {
244
244
  item_content = [#strong[#heading_buffer.] #item_content]
245
- heading_buffer = none
245
+ } else {
246
+ if any_emitted { blank-line() }
247
+ strong[#heading_buffer.]
248
+ any_emitted = true
246
249
  }
250
+ heading_buffer = none
251
+ }
252
+
253
+ // Buffer this heading for the next element to run into. The level it was
254
+ // captured at rides along: the run-in test needs it, and the buffer is
255
+ // read one iteration later, by which time `item` is the *next* element.
256
+ if kind == "heading" {
257
+ heading_buffer = item_content
258
+ heading_level = item.nest_level
259
+ continue
247
260
  }
248
261
 
249
262
  // Format based on element kind
@@ -336,6 +349,15 @@
336
349
  block[#final_par]
337
350
  }
338
351
  }
352
+
353
+ // A heading with nothing after it never ran into anything, and the buffer
354
+ // dies with the loop unless it is drained here. Sticky for the same reason
355
+ // the last item is: a standalone heading is one line, so it keeps with the
356
+ // signature block rather than opening a page alone.
357
+ if heading_buffer != none {
358
+ if any_emitted { blank-line() }
359
+ block(sticky: true)[#strong[#heading_buffer.]]
360
+ }
339
361
  }
340
362
  }
341
363
 
@@ -221,13 +221,12 @@
221
221
  render-date-section(actual_date, memo-style: memo_style)
222
222
  render-for-section(memo_for, memo_for_cols)
223
223
  if not falsey(memo_from) { render-from-section(memo_from) }
224
- let single-ref = if type(references) == array and references.len() == 1 {
225
- references.at(0)
226
- } else {
227
- none
228
- }
224
+ // Blank entries are dropped first so a stub `- ` left under `references:`
225
+ // cannot pass as the lone reference and render an empty `()` after the subject.
226
+ let refs = compact-references(references)
227
+ let single-ref = if refs.len() == 1 { refs.at(0) } else { none }
229
228
  render-subject-section(subject, inline-reference: single-ref)
230
- render-references-section(references)
229
+ render-references-section(refs)
231
230
 
232
231
  // AFH 33-337: "Begin text on second line below subject/references".
233
232
  // Emitted here (not inside body.typ) so the v() lands at the same lexical
@@ -22,9 +22,11 @@
22
22
  date: none,
23
23
  // Format of indorsement: "standard" (same page), "informal" (no header), or "separate_page" (starts on new page)
24
24
  format: "standard",
25
- // Approval action: none (default, no action line displayed), "undecided", "approve", or "disapprove".
26
- // When set to "undecided", the action line is displayed with neither option circled.
27
- // When set to "approve" or "disapprove", the action line is displayed with the selected option circled.
25
+ // Decision action. `none` (default) displays no action line at all.
26
+ // Approval authority pair: "approve" or "disapprove" circles the selected
27
+ // option; "undecided" displays the pair with neither circled.
28
+ // Coordination pair: "concur" or "nonconcur" circles the selected option;
29
+ // "undecided_concur" displays the pair with neither circled.
28
30
  action: none,
29
31
  content,
30
32
  ) = {
@@ -39,23 +39,36 @@
39
39
  let band-top = -band-height / 2 // puts the band center at dy 0
40
40
  let band-center = band-top + band-height / 2
41
41
 
42
+ // The centered title/caption is placed content (not width-constrained), so a
43
+ // long caption would otherwise render on one line and run underneath the seal.
44
+ // Bound the caption to the width that stays clear of the seal on both sides
45
+ // and auto-shrink it to fit: the seal reaches `band-height - corner-overhang`
46
+ // into the text area, so reserving that plus a gutter on each side keeps the
47
+ // centered caption from touching the seal. `layout` resolves the text-area
48
+ // width so the bound tracks the page/margins.
49
+ let caption-gutter = 0.25in
50
+ let caption-clear = (band-height - corner-overhang) + caption-gutter
42
51
  place(
43
52
  dy: 0.625in - spacing.margin,
44
53
  box(
45
54
  width: 100%,
46
55
  fill: none,
47
56
  stroke: none,
48
- [
49
- #place(
50
- center + top,
51
- align(center)[
52
- #set text(12pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")
53
- #title\
54
- #v(1pt)
55
- #text(10.5pt)[#caption]
56
- ],
57
- )
58
- ],
57
+ layout(size => place(
58
+ center + top,
59
+ align(center)[
60
+ #set text(12pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")
61
+ #title\
62
+ #v(1pt)
63
+ #if not falsey(caption) {
64
+ fit-to-width(
65
+ size.width - 2 * caption-clear,
66
+ alignment: center,
67
+ box(text(10.5pt)[#caption]),
68
+ )
69
+ }
70
+ ],
71
+ )),
59
72
  ),
60
73
  )
61
74
 
@@ -145,11 +158,32 @@
145
158
  )
146
159
  }
147
160
 
161
+ /// Whether a `references` entry carries no citation text.
162
+ ///
163
+ /// Blank entries reach the template routinely: a template author leaves a
164
+ /// stub `- ` under `references:` for the user to fill in, and the quillmark
165
+ /// helper hands back `""` for an unset or whitespace-only markdown field.
166
+ /// Such an entry must not count as a reference — otherwise a lone blank one
167
+ /// satisfies the "exactly one reference" test below and renders as an empty
168
+ /// `()` after the subject.
169
+ ///
170
+ /// - entry (any): A single `references` element
171
+ /// -> bool
172
+ #let blank-reference(entry) = falsey(entry) or entry == []
173
+
174
+ /// Drops `references` entries that carry no citation text and normalizes the
175
+ /// result to an array, so blank placeholder entries neither render on their
176
+ /// own nor affect the inline-vs-block decision.
177
+ ///
178
+ /// - references (array | none): Raw reference entries
179
+ /// -> array
180
+ #let compact-references(references) = ensure-array(references).filter(entry => not blank-reference(entry))
181
+
148
182
  // AFH 33-337 "SUBJECT:": "In all uppercase letters place 'SUBJECT:', flush with the
149
183
  // left margin, on the second line below the last line of the FROM element"
150
184
  #let render-subject-section(subject-text, inline-reference: none) = {
151
185
  blank-line()
152
- let content = if inline-reference != none {
186
+ let content = if not blank-reference(inline-reference) {
153
187
  [#subject-text (#box(inline-reference))]
154
188
  } else {
155
189
  [#subject-text]
@@ -163,7 +197,8 @@
163
197
  // AFH 33-337: only render References block for two or more references.
164
198
  // A single reference is rendered inline after the SUBJECT text instead.
165
199
  #let render-references-section(references) = {
166
- if type(references) == array and references.len() >= 2 {
200
+ let references = compact-references(references)
201
+ if references.len() >= 2 {
167
202
  blank-line()
168
203
  grid(
169
204
  columns: (auto, auto, 1fr),
@@ -245,40 +280,57 @@
245
280
  // =============================================================================
246
281
  // ACTION LINE RENDERING
247
282
  // =============================================================================
248
- // Renders the Approve / Disapprove action line for indorsement memos.
249
- // action: "undecided" = both options rendered plain (no circle),
250
- // "approve" = Approve circled, "disapprove" = Disapprove circled.
283
+ // Renders the decision line for indorsement memos — an "either / or" pair
284
+ // where the endorser's choice is circled and the rejected option struck out.
285
+ //
286
+ // Two option pairs are supported, reflecting the two roles an indorsement
287
+ // plays in a coordination chain: coordinating officials Concur / Nonconcur,
288
+ // while the final approval authority Approves / Disapproves.
289
+ //
290
+ // Each pair also has an "undecided" form that renders both options plain, for
291
+ // printing a memo the endorser circles by hand when signing.
292
+ //
251
293
  // Empty/none suppression is handled by the caller before this is invoked.
252
294
 
295
+ // Maps each `action` value to the option pair it renders and the index of the
296
+ // selected option within that pair (`none` selects neither).
297
+ #let ACTION_FORMS = (
298
+ "undecided": (("Approve", "Disapprove"), none),
299
+ "approve": (("Approve", "Disapprove"), 0),
300
+ "disapprove": (("Approve", "Disapprove"), 1),
301
+ "undecided_concur": (("Concur", "Nonconcur"), none),
302
+ "concur": (("Concur", "Nonconcur"), 0),
303
+ "nonconcur": (("Concur", "Nonconcur"), 1),
304
+ )
305
+
253
306
  #let render-action-line(action, trailing-blank-line: true) = {
254
307
  assert(
255
- action in ("undecided", "approve", "disapprove"),
256
- message: "action must be \"undecided\", \"approve\", or \"disapprove\"",
308
+ action in ACTION_FORMS,
309
+ message: "action must be one of " + ACTION_FORMS.keys().map(k => "\"" + k + "\"").join(", "),
257
310
  )
311
+ let (options, selected) = ACTION_FORMS.at(action)
312
+ // Circle the selected option using a box with rounded corners; strike the
313
+ // one it was chosen over. When neither is selected both render plain.
314
+ // Use baseline parameter to maintain vertical text alignment.
315
+ let render-option(index) = {
316
+ let option = options.at(index)
317
+ if selected == none {
318
+ option
319
+ } else if selected == index {
320
+ box(stroke: 0.5pt + black, radius: 2pt, inset: 2pt, baseline: 2pt)[#option]
321
+ } else {
322
+ strike(option)
323
+ }
324
+ }
258
325
  // No leading blank-line: the caller (indorsement.typ) already emits the
259
326
  // header→content gap once. The action line's `block(sticky: true)`
260
327
  // additionally inherits `block.above: spacing.line` so the visual gap
261
328
  // above matches the gap above a body's first paragraph.
262
- // Circle the selected option using a box with rounded corners
263
- // Use baseline parameter to maintain vertical text alignment
264
- let approve-text = if action == "approve" {
265
- box(stroke: 0.5pt + black, radius: 2pt, inset: 2pt, baseline: 2pt)[Approve]
266
- } else if action == "disapprove" {
267
- strike[Approve]
268
- } else {
269
- [Approve]
270
- }
271
- let disapprove-text = if action == "disapprove" {
272
- box(stroke: 0.5pt + black, radius: 2pt, inset: 2pt, baseline: 2pt)[Disapprove]
273
- } else if action == "approve" {
274
- strike[Disapprove]
275
- } else {
276
- [Disapprove]
277
- }
329
+ //
278
330
  // Keep the action line with the following content (body or signature block)
279
331
  // using the same sticky-block pattern that body.typ applies to the last
280
332
  // paragraph, per AFH 33-337 §11 orphan-prevention rules.
281
- block(sticky: true)[#approve-text / #disapprove-text]
333
+ block(sticky: true)[#render-option(0) / #render-option(1)]
282
334
  // Trailing blank-line places the body's first paragraph one line below
283
335
  // the action, mirroring the gap above it. Suppressed when the body is
284
336
  // empty so the signature block's own 4-line gap lands on AFH 33-337's
@@ -87,12 +87,47 @@
87
87
  ]
88
88
  }
89
89
 
90
+ /// Shrinks content to fit within a maximum width, never enlarging it.
91
+ ///
92
+ /// Measures `body` at its current font size and scales it down uniformly only
93
+ /// when it is wider than `width`; content that already fits keeps its set size
94
+ /// (the scale is capped at 100%). Unlike `fit-box`, height flows naturally and
95
+ /// small content is never scaled up. Used for the letterhead caption (unit
96
+ /// designation): a long name is shrunk to the width that clears the seal rather
97
+ /// than running underneath it. Pass a non-wrapping `box` as `body` so the
98
+ /// measured width is the single-line width and the result stays on one line.
99
+ ///
100
+ /// Empty content measures 0pt wide and has no scale to compute, so it is returned
101
+ /// as-is rather than divided by; a non-positive `width` likewise admits no scale
102
+ /// that fits, so the body is left at its set size instead of being flipped or
103
+ /// collapsed.
104
+ ///
105
+ /// - width (length): Maximum width the content may occupy
106
+ /// - alignment (alignment): Horizontal placement within the reserved width
107
+ /// - body (content): Content to fit (typically a non-wrapping `box`)
108
+ /// -> content
109
+ #let fit-to-width(width, alignment: left, body) = context {
110
+ let s = measure(body)
111
+ if s.width <= 0pt or width <= 0pt { return body }
112
+ let f = calc.min(1, width / s.width) * 100% // ratio, capped so it only shrinks
113
+ box(width: width)[
114
+ #align(alignment)[
115
+ #scale(f, reflow: true)[#body]
116
+ ]
117
+ ]
118
+ }
119
+
90
120
  /// Formats a date for the memo heading.
91
121
  ///
92
- /// - String: shown as-is (use for fixed text like placeholders).
93
- /// - datetime: USAF style `DD Month YYYY`; DAF style `Month DD, YYYY`.
122
+ /// Dispatches on the shape a date field can arrive in:
123
+ /// - str: shown as-is (fixed text like a placeholder).
124
+ /// - datetime: a native Typst datetime (e.g. `datetime.today()`) — USAF style
125
+ /// `DD Month YYYY`, DAF style `Month DD, YYYY`.
126
+ /// - dict: a Quillmark `date`/`datetime` field lowers to a click-to-edit value
127
+ /// object `{ value: datetime, display: closure }`; `display` is a stored closure
128
+ /// over `.display`, so call it through parentheses — a dict has no `.display` method.
94
129
  ///
95
- /// - date (str|datetime): Date to format for display
130
+ /// - date (str | datetime | dict): Date to format for display
96
131
  /// - memo-style (str): `"usaf"` or `"daf"`
97
132
  /// -> content
98
133
  #let display-date(date, memo-style: "usaf") = {
@@ -108,7 +143,11 @@
108
143
  } else {
109
144
  "[day padding:none] [month repr:long] [year]"
110
145
  }
111
- date.display(pattern)
146
+ if type(date) == datetime {
147
+ date.display(pattern)
148
+ } else {
149
+ (date.display)(pattern)
150
+ }
112
151
  }
113
152
  }
114
153
 
@@ -266,7 +305,10 @@
266
305
  if value == none {
267
306
  ""
268
307
  } else if type(value) == array {
269
- value.join(separator)
308
+ // `().join(sep)` is `none`, not `""` — coerce so an empty array keeps the
309
+ // documented contract and stays `falsey` for callers that test the result.
310
+ let joined = value.join(separator)
311
+ if joined == none { "" } else { joined }
270
312
  } else {
271
313
  str(value)
272
314
  }