@airmark/quiver 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airmark/quiver",
3
- "version": "0.15.0",
3
+ "version": "0.18.0",
4
4
  "description": "Airmark Quiver — Air Force and DAF document Quills for Quillmark.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "devDependencies": {
19
19
  "@quillmark/quiver": "^0.7.0",
20
- "@quillmark/wasm": "0.75.0"
20
+ "@quillmark/wasm": "^0.78.0"
21
21
  },
22
22
  "scripts": {
23
23
  "test": "node --test quiver.test.js",
@@ -35,6 +35,6 @@
35
35
  "license": "Apache-2.0",
36
36
  "repository": {
37
37
  "type": "git",
38
- "url": "https://github.com/nibsbin/airmark-quiver"
38
+ "url": "https://github.com/tonguetoquill/airmark-quiver"
39
39
  }
40
40
  }
@@ -28,7 +28,7 @@ main:
28
28
  type: string
29
29
  description: Commander's authentication entry on page 1.
30
30
 
31
- card_types:
31
+ leaf_kinds:
32
32
  experience:
33
33
  description: Each card represents one row in the Record of Experience table. Rows fill page 1 (up to 16 rows) then page 2 (up to 21 rows), for a maximum of 37 rows.
34
34
  ui:
@@ -11,7 +11,7 @@ commanders_auth: ""
11
11
  ---
12
12
 
13
13
  <!-- ═══════════════════════════════════════════════════════════════
14
- RECORD OF EXPERIENCE CARDS
14
+ RECORD OF EXPERIENCE LEAVES
15
15
  Each card below becomes one row in the experience table.
16
16
  Page 1 holds up to 16 rows; Page 2 holds up to 21 rows (37 max).
17
17
 
@@ -29,7 +29,7 @@ commanders_auth: ""
29
29
 
30
30
  <!-- Example 1: Initial assignment to a unit -->
31
31
  ---
32
- CARD: experience
32
+ KIND: experience
33
33
  date: 2025-01-15
34
34
  action: "Assigned to 1 ACCS/DOT as Weapons Director"
35
35
  written_grade: ""
@@ -41,7 +41,7 @@ auth_or_remarks: "Initial assignment"
41
41
 
42
42
  <!-- Example 2: Written upgrade evaluation -->
43
43
  ---
44
- CARD: experience
44
+ KIND: experience
45
45
  date: 2025-03-15
46
46
  action: "Completed written upgrade evaluation for 3-Level"
47
47
  written_grade: "3"
@@ -53,7 +53,7 @@ auth_or_remarks: "Per AFMAN 13-1CRCV1"
53
53
 
54
54
  <!-- Example 3: Positional (live-environment) upgrade / certification -->
55
55
  ---
56
- CARD: experience
56
+ KIND: experience
57
57
  date: 2025-06-20
58
58
  action: "Certified Mission Ready — positional upgrade"
59
59
  written_grade: ""
@@ -65,7 +65,7 @@ auth_or_remarks: "SSgt Jones, T.R."
65
65
 
66
66
  <!-- Example 4: Combined written + positional upgrade (both columns filled) -->
67
67
  ---
68
- CARD: experience
68
+ KIND: experience
69
69
  date: 2025-08-01
70
70
  action: "Upgrade evaluation — written and positional"
71
71
  written_grade: "4"
@@ -77,7 +77,7 @@ auth_or_remarks: "Verified by unit commander"
77
77
 
78
78
  <!-- Example 5: PCS departure — no written/positional grade; omit grade dates (or use "") -->
79
79
  ---
80
- CARD: experience
80
+ KIND: experience
81
81
  date: 2025-09-01
82
82
  action: "PCS to 726 ACS/MOC, Tinker AFB OK"
83
83
  written_grade: ""
@@ -35,14 +35,14 @@
35
35
  #if "grade" in data { vals.insert("commonforms_text_p1_3", data.grade) }
36
36
  #if "commanders_auth" in data { vals.insert("commonforms_text_p1_116", data.commanders_auth) }
37
37
 
38
- // --- Experience table rows from cards ---
38
+ // --- Experience table rows from LEAVES ---
39
39
  // The form supports 16 rows on page 1 and 21 rows on page 2 (37 total).
40
40
  // Overflow rows are silently ignored.
41
41
  #let max-rows = 37
42
42
  #{
43
43
  let row = 0
44
- for card in data.CARDS {
45
- if card.CARD == "experience" {
44
+ for card in data.LEAVES {
45
+ if card.KIND == "experience" {
46
46
  if row < max-rows {
47
47
  for (col, key) in col-keys.enumerate() {
48
48
  let value = form-cell(card.at(key, default: none))
@@ -39,7 +39,7 @@ main:
39
39
  unit_commander_rank_name_and_telephone:
40
40
  type: string
41
41
 
42
- card_types:
42
+ leaf_kinds:
43
43
  accomplishments_continued:
44
44
  description: Optional. Add this card only when page 2 overflow is required. Use the card body for continued bullets. Rank/name on page 2 matches the main nominee line automatically.
45
45
  ui:
@@ -16,7 +16,7 @@ Use this main body for page 1 accomplishments.
16
16
  - Replace all placeholder values.
17
17
 
18
18
  ---
19
- CARD: accomplishments_continued
19
+ KIND: accomplishments_continued
20
20
  ---
21
21
 
22
22
  Use this card body only if page 2 overflow is needed.
@@ -38,8 +38,8 @@
38
38
  }
39
39
 
40
40
  #let continued-key = "SPECIFIC_ACCOMPLISHMENTS_Use_Performance_Statements_IAW_DAFMAN_362806_Continued"
41
- #for card in data.CARDS {
42
- if card.CARD == "accomplishments_continued" and continued-key not in vals and "BODY" in card {
41
+ #for card in data.LEAVES {
42
+ if card.KIND == "accomplishments_continued" and continued-key not in vals and "BODY" in card {
43
43
  vals.insert(continued-key, card.BODY)
44
44
  }
45
45
  }
@@ -81,7 +81,7 @@ main:
81
81
  type: string
82
82
  description: "Put in your Emergency Contact's Phone Number."
83
83
 
84
- card_types:
84
+ leaf_kinds:
85
85
  itinerary:
86
86
  description: "Each card represents one row in the travel itinerary table. Up to 10 rows are supported."
87
87
  ui:
@@ -16,7 +16,7 @@ emergency_contact_phone: "999-999-9999"
16
16
  ---
17
17
 
18
18
  ---
19
- CARD: itinerary
19
+ KIND: itinerary
20
20
  date: 2026-03-26
21
21
  departure_point: "Biloxi, MS"
22
22
  arrival_point: "Fake City, MS"
@@ -25,7 +25,7 @@ mileage: "250"
25
25
  ---
26
26
 
27
27
  ---
28
- CARD: itinerary
28
+ KIND: itinerary
29
29
  date: 2026-03-29
30
30
  departure_point: "Fake City, MS"
31
31
  arrival_point: "Biloxi, MS"
@@ -29,14 +29,14 @@
29
29
  #tf(40pt, 100pt)[#show-date(data.at("departure_date", default: none))]
30
30
  #tf(123pt, 100pt)[#data.at("final_destination", default: "")]
31
31
 
32
- // Itinerary Rows (via CARDS)
32
+ // Itinerary Rows (via LEAVES)
33
33
  #{
34
34
  let row = 0
35
35
  let dy-start = 160pt
36
36
  let dy-step = 40pt
37
- if "CARDS" in data {
38
- for card in data.CARDS {
39
- if card.CARD == "itinerary" and row < 10 {
37
+ if "LEAVES" in data {
38
+ for card in data.LEAVES {
39
+ if card.KIND == "itinerary" and row < 10 {
40
40
  let dy = dy-start + (row * dy-step)
41
41
  tf(80pt, dy)[#show-date(card.at("date", default: none))]
42
42
  tf(135pt, dy)[#card.at("departure_point", default: "")]
@@ -4,7 +4,7 @@ quill:
4
4
  backend: typst
5
5
  plate_file: plate.typ
6
6
  example_file: example.md
7
- description: Typesetted USAF Official Memorandum
7
+ description: U.S. Air Force and Space Force Official Memorandum
8
8
 
9
9
  main:
10
10
  body:
@@ -182,7 +182,7 @@ main:
182
182
  compact: true
183
183
  description: "Optional marking appended after double slash (e.g. NF with classification CUI renders as CUI//NF). Leave blank to show only the classification with no //."
184
184
 
185
- card_types:
185
+ leaf_kinds:
186
186
  indorsement:
187
187
  description: Chain of routing endorsements. Each endorsement block adds an official response or forwarding action to the original memo.
188
188
  ui:
@@ -225,13 +225,14 @@ card_types:
225
225
  action:
226
226
  type: string
227
227
  enum:
228
- - none
228
+ - ""
229
229
  - undecided
230
230
  - approve
231
231
  - disapprove
232
+ default: ""
232
233
  ui:
233
234
  group: Addressing
234
- description: "Action taken by the endorser. Use 'none' to hide the Approve/Disapprove line entirely, 'undecided' to display the line with neither option circled, 'approve' or 'disapprove' to circle the selected option."
235
+ 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."
235
236
  attachments:
236
237
  type: array
237
238
  ui:
@@ -43,7 +43,7 @@ The `usaf_memo` Quill package takes care of all 33-337 formatting details. Focus
43
43
  Do not include a complimentary close (e.g. "Respectfully,") in official memorandums.
44
44
 
45
45
  ---
46
- CARD: indorsement
46
+ KIND: indorsement
47
47
  for: ORG/SYMBOL
48
48
  format: standard
49
49
  from: ORG/SYMBOL
@@ -53,4 +53,4 @@ signature_block:
53
53
  - Duty Title
54
54
  ---
55
55
 
56
- This body and the metdata above are an indorsement card. Multiple or no indorsements cards can be used.
56
+ This body and the metdata above are an indorsement card. Multiple or no indorsements leaves can be used.
@@ -12,13 +12,14 @@
12
12
  #let backmatter(
13
13
  signature_block: none,
14
14
  signature_blank_lines: 4,
15
+ signing_field: none,
15
16
  attachments: none,
16
17
  cc: none,
17
18
  distribution: none,
18
19
  leading_pagebreak: false,
19
20
  ) = {
20
21
  // Render backmatter sections without paragraph numbering
21
- render-signature-block(signature_block, signature-blank-lines: signature_blank_lines)
22
+ render-signature-block(signature_block, signature-blank-lines: signature_blank_lines, signing-field: signing_field)
22
23
  render-backmatter-sections(
23
24
  attachments: attachments,
24
25
  cc: cc,
@@ -200,7 +200,7 @@
200
200
  context {
201
201
  let heading_buffer = none
202
202
  // Filter out zero-width paragraphs so that an empty body (e.g. empty
203
- // string from a CARD with no text) emits nothing and collapses to zero
203
+ // string from a KIND with no text) emits nothing and collapses to zero
204
204
  // vertical space. Tables are always kept regardless of measured width.
205
205
  let items = PAR_BUFFER.get().filter(item =>
206
206
  item.kind == "table" or measure(item.content).width > 0pt
@@ -136,14 +136,14 @@
136
136
  LINE_STRIDE.update(line-stride)
137
137
  }
138
138
 
139
- metadata((
139
+ [#metadata((
140
140
  subject: subject,
141
141
  original_date: actual_date,
142
142
  original_from: first-or-value(memo_from),
143
143
  body_font: body_font,
144
144
  font_size: font_size,
145
145
  memo_style: memo_style,
146
- ))
146
+ )) <usaf-memo-config>]
147
147
 
148
148
  render-date-section(actual_date, memo-style: memo_style)
149
149
  render-for-section(memo_for, memo_for_cols)
@@ -18,6 +18,7 @@
18
18
  to: none,
19
19
  signature_block: none,
20
20
  signature_blank_lines: 4,
21
+ signing_field: none,
21
22
  attachments: none,
22
23
  cc: none,
23
24
  date: none,
@@ -45,7 +46,7 @@
45
46
  let ind_from = first-or-value(from)
46
47
  let ind_for = to
47
48
 
48
- // An empty body (e.g. a CARD with only an action selected and no markdown
49
+ // An empty body (e.g. a KIND with only an action selected and no markdown
49
50
  // body) collapses to zero rendered layout via render-body's filter. To
50
51
  // make the "empty body takes no layout space" guarantee end-to-end, also
51
52
  // suppress the spacing the surrounding code reserves *for* the body:
@@ -57,12 +58,18 @@
57
58
  // or whitespace-only; comparing against `[]` reliably detects that.
58
59
  let body_empty = content == []
59
60
 
61
+ let effective_action = if action == none or type(action) != str or action.trim() == "" {
62
+ none
63
+ } else {
64
+ action
65
+ }
66
+
60
67
  if format != "informal" {
61
68
  // Step the counter BEFORE the context block to avoid read-then-update loop
62
69
  counters.indorsement.step()
63
70
 
64
71
  context {
65
- let config = query(metadata).last().value
72
+ let config = query(<usaf-memo-config>).first().value
66
73
  let memo-style = config.at("memo_style", default: "usaf")
67
74
  let original_subject = config.subject
68
75
  let original_date = config.original_date
@@ -103,27 +110,24 @@
103
110
  }
104
111
  // Header→content gap. Skipped when there is neither an action line nor
105
112
  // body to follow — render-signature-block supplies its own 4-line gap.
106
- if (action != none and action != "none") or not body_empty {
113
+ if effective_action != none or not body_empty {
107
114
  blank-line()
108
115
  }
109
116
  }
110
117
 
111
118
  // Show action line only when an action decision is set (not `none`)
112
- if action != none and action != "none" {
113
- render-action-line(action, trailing-blank-line: not body_empty)
119
+ if effective_action != none {
120
+ render-action-line(effective_action, trailing-blank-line: not body_empty)
114
121
  }
115
122
 
116
123
  if not body_empty {
117
124
  context {
118
- let memo-style = {
119
- let items = query(metadata)
120
- if items.len() > 0 { items.last().value.at("memo_style", default: "usaf") } else { "usaf" }
121
- }
125
+ let memo-style = query(<usaf-memo-config>).first().value.at("memo_style", default: "usaf")
122
126
  render-body(content, memo-style: memo-style)
123
127
  }
124
128
  }
125
129
 
126
- render-signature-block(signature_block, signature-blank-lines: signature_blank_lines)
130
+ render-signature-block(signature_block, signature-blank-lines: signature_blank_lines, signing-field: signing_field)
127
131
 
128
132
  render-backmatter-sections(attachments: attachments, cc: cc)
129
133
  }
@@ -21,7 +21,7 @@
21
21
  /// - content (content): The body content to render
22
22
  /// -> content
23
23
  #let mainmatter(it) = context {
24
- let config = query(metadata).last().value
24
+ let config = query(<usaf-memo-config>).first().value
25
25
  let memo-style = config.at("memo_style", default: "usaf")
26
26
  render-body(it, memo-style: memo-style)
27
27
  }
@@ -54,16 +54,16 @@
54
54
  } else {
55
55
  // Isolate seal column from document `font_size`: stack `em` spacing and subtitle
56
56
  // must not scale with body text (see frontmatter `set text(size: font_size)`).
57
- block(width: 2in)[
57
+ // Subtitle is wrapped in `box` so it stays on one line and may extend past
58
+ // the seal's 2in column rather than wrapping.
59
+ block[
58
60
  #set text(9pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")
59
- #align(left)[
60
- // Spacing applies between positional stack children only, not one `[…]` body.
61
- #stack(
62
- spacing: 0.5em,
63
- fit-box(width: 2in, height: 1in)[#letterhead-seal],
64
- upper(ensure-string(letterhead-seal-subtitle)),
65
- )
66
- ]
61
+ // Spacing applies between positional stack children only, not one `[…]` body.
62
+ #stack(
63
+ spacing: 0.5em,
64
+ fit-box(width: 2in, height: 1in)[#letterhead-seal],
65
+ box(upper(ensure-string(letterhead-seal-subtitle))),
66
+ )
67
67
  ]
68
68
  }
69
69
  place(
@@ -148,7 +148,7 @@
148
148
  // AFH 33-337 long-name example: "Signature block adjusted to the left" when a
149
149
  // long name would otherwise exceed the right margin.
150
150
 
151
- #let render-signature-block(signature-lines, signature-blank-lines: 4) = {
151
+ #let render-signature-block(signature-lines, signature-blank-lines: 4, signing-field: none) = {
152
152
  signature-lines = ensure-array(signature-lines)
153
153
  // AFH 33-337: "fifth line below" = 4 blank lines between text and signature block.
154
154
  // breakable: false discourages orphaning the signature block onto a page by itself.
@@ -177,6 +177,20 @@
177
177
  default-pad
178
178
  }
179
179
  block(breakable: false)[
180
+ #if signing-field != none {
181
+ let stride = {
182
+ let s = LINE_STRIDE.get()
183
+ if s == none {
184
+ let one-line = measure(par(spacing: 0pt)[x]).height
185
+ measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
186
+ } else { s }
187
+ }
188
+ place(
189
+ dx: left-pad,
190
+ dy: -(stride * signature-blank-lines),
191
+ box(width: body-width - left-pad, height: stride * signature-blank-lines, signing-field),
192
+ )
193
+ }
180
194
  #align(left)[
181
195
  #pad(left: left-pad)[
182
196
  #text(hyphenate: false)[
@@ -196,16 +210,14 @@
196
210
  // ACTION LINE RENDERING
197
211
  // =============================================================================
198
212
  // Renders the Approve / Disapprove action line for indorsement memos.
199
- // action: "none" = no action line displayed (hidden), "undecided" = both options
200
- // rendered plain (no circle), "approve" = Approve circled,
201
- // "disapprove" = Disapprove circled. The action line is rendered when
202
- // action is "undecided", "approve", or "disapprove". "none" suppresses
203
- // the line entirely (handled by the caller).
213
+ // action: "undecided" = both options rendered plain (no circle),
214
+ // "approve" = Approve circled, "disapprove" = Disapprove circled.
215
+ // Empty/none suppression is handled by the caller before this is invoked.
204
216
 
205
217
  #let render-action-line(action, trailing-blank-line: true) = {
206
218
  assert(
207
- action in ("none", "undecided", "approve", "disapprove"),
208
- message: "action must be \"none\", \"undecided\", \"approve\", or \"disapprove\"",
219
+ action in ("undecided", "approve", "disapprove"),
220
+ message: "action must be \"undecided\", \"approve\", or \"disapprove\"",
209
221
  )
210
222
  // No leading blank-line: the caller (indorsement.typ) already emits the
211
223
  // header→content gap once. The action line's `block(sticky: true)`
@@ -1,4 +1,4 @@
1
- #import "@local/quillmark-helper:0.1.0": data
1
+ #import "@local/quillmark-helper:0.1.0": data, signature-field
2
2
  #import "@local/tonguetoquill-usaf-memo:3.0.0": backmatter, frontmatter, indorsement, mainmatter
3
3
 
4
4
  // Frontmatter configuration
@@ -57,6 +57,7 @@
57
57
  #backmatter(
58
58
  // Signature block
59
59
  signature_block: data.signature_block,
60
+ signing_field: signature-field("Signature"),
60
61
 
61
62
  // Optional cc
62
63
  ..if "cc" in data { (cc: data.cc) },
@@ -68,9 +69,9 @@
68
69
  ..if "attachments" in data { (attachments: data.attachments) },
69
70
  )
70
71
 
71
- // Indorsements - iterate through CARDS array and filter by CARD type
72
- #for card in data.CARDS {
73
- if card.CARD == "indorsement" {
72
+ // Indorsements - iterate through LEAVES array and filter by KIND type
73
+ #for (i, card) in data.LEAVES.enumerate() {
74
+ if card.KIND == "indorsement" {
74
75
  // The quillmark helper leaves an unset/whitespace-only markdown body as
75
76
  // the empty string `""`; only non-empty bodies are eval'd into content.
76
77
  // Pass truly empty content (`[]`) in the empty case so indorsement can
@@ -90,6 +91,7 @@
90
91
  from: card.at("from", default: ""),
91
92
  to: card.at("for", default: ""),
92
93
  signature_block: card.signature_block,
94
+ signing_field: signature-field("Ind_" + str(i) + "_Signature"),
93
95
  ..if "attachments" in card { (attachments: card.attachments) },
94
96
  ..if "cc" in card { (cc: card.cc) },
95
97
  format: card.at("format", default: "standard"),