@airmark/quiver 0.6.0 → 0.8.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/README.md CHANGED
@@ -17,7 +17,7 @@ and aligned to the current Quillmark spec.
17
17
  ## Install
18
18
 
19
19
  ```bash
20
- npm install @airmark/quiver @quillmark/quiver@0.5.0 @quillmark/wasm@0.60.0
20
+ npm install @airmark/quiver @quillmark/quiver@0.5.0 @quillmark/wasm@0.66.2
21
21
  ```
22
22
 
23
23
  ## Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airmark/quiver",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Airmark Quiver — Air Force and DAF document Quills for Quillmark.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -22,11 +22,11 @@
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@quillmark/quiver": ">=0.5.0 <1.0.0",
25
- "@quillmark/wasm": ">=0.60.0 <1.0.0"
25
+ "@quillmark/wasm": ">=0.66.2 <1.0.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@quillmark/quiver": "^0.5.0",
29
- "@quillmark/wasm": "0.60.0"
29
+ "@quillmark/wasm": "0.66.2"
30
30
  },
31
31
  "scripts": {
32
32
  "test": "node --test",
@@ -153,15 +153,16 @@ main:
153
153
  description: "USAF memorandum (default) or DAF headquarters memorandum. DAF changes date formatting and body paragraph layout per AFH 33-337."
154
154
 
155
155
  font_size:
156
- title: Font size for the memo text (int pt)
157
- type: integer
156
+ title: Font size for the memo text (pt)
157
+ type: number
158
158
  default: 12
159
159
  examples:
160
160
  - 12
161
+ - 11.5
161
162
  ui:
162
163
  group: Additional
163
164
  compact: true
164
- description: Font size for the memo text (pt).
165
+ description: Font size for the memo text (pt). Decimal values (e.g. 11.5) are supported.
165
166
 
166
167
  date:
167
168
  title: Date of memo (YYYY-MM-DD); defaults to today
@@ -174,13 +175,27 @@ main:
174
175
  classification:
175
176
  title: Classification level of the memo that displays in the banner
176
177
  type: string
177
- default: ""
178
- examples:
178
+ enum:
179
+ - ""
180
+ - UNCLASSIFIED
181
+ - CUI
179
182
  - CONFIDENTIAL
183
+ - SECRET
184
+ - TOP SECRET
185
+ default: ""
186
+ ui:
187
+ group: Additional
188
+ compact: true
189
+ description: "Select the classification marking displayed in the header/footer banner. Banner colors follow DoD/CAPCO marking guidance (UNCLASSIFIED green, CUI/CONFIDENTIAL black, SECRET red, TOP SECRET orange). Follow AFI 31-401 and applicable DoD guidance. Leave blank to omit the banner."
190
+
191
+ dissemination:
192
+ title: Dissemination control marking (portion after //)
193
+ type: string
194
+ default: ""
180
195
  ui:
181
196
  group: Additional
182
197
  compact: true
183
- description: Follow AFI 31-401 and applicable DoD guidance for classification markings. Leave blank for unclassified.
198
+ 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
199
 
185
200
  card_types:
186
201
  indorsement:
@@ -30,7 +30,7 @@ signature_block:
30
30
  - FIRST M. LAST, Rank, USAF
31
31
  - Duty Title
32
32
  tag_line: Aim High
33
- classification: SECRET//FICTIONAL
33
+ classification: UNCLASSIFIED
34
34
  ---
35
35
 
36
36
  The `usaf_memo` Quill package takes care of all 33-337 formatting details. Focus on the content.
@@ -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 cards can be used.
@@ -90,7 +90,7 @@
90
90
  // - "A single paragraph is not numbered" (§2)
91
91
  // - First paragraph flush left, never indented
92
92
  // - Indent sub-paragraphs to align with first character of parent paragraph text
93
- #let render-body(content, auto-numbering: true, memo-style: "usaf") = {
93
+ #let render-body(content, memo-style: "usaf") = {
94
94
  let PAR_BUFFER = state("PAR_BUFFER")
95
95
  PAR_BUFFER.update(())
96
96
  let NEST_DOWN = counter("NEST_DOWN")
@@ -199,9 +199,15 @@
199
199
  // item.kind — "par", "heading", "table", or "continuation"
200
200
  context {
201
201
  let heading_buffer = none
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
204
+ // vertical space. Tables are always kept regardless of measured width.
205
+ let items = PAR_BUFFER.get().filter(item =>
206
+ item.kind == "table" or measure(item.content).width > 0pt
207
+ )
208
+ if items.len() == 0 { return }
202
209
  // Only top-level paragraphs count for AFH 33-337 §2 numbering purposes
203
- let par_count = PAR_BUFFER.get().filter(item => item.kind == "par").len()
204
- let items = PAR_BUFFER.get()
210
+ let par_count = items.filter(item => item.kind == "par").len()
205
211
  let total_count = items.len()
206
212
 
207
213
  // Track paragraph numbers per level manually to avoid nested-context
@@ -214,6 +220,7 @@
214
220
  }
215
221
 
216
222
  let i = 0
223
+ let any_emitted = false
217
224
  for item in items {
218
225
  i += 1
219
226
  let kind = item.kind
@@ -253,18 +260,10 @@
253
260
  // Continuation block within a multi-block list item:
254
261
  // indent to align with preceding numbered paragraph's text, no new number.
255
262
  // level-counts still holds the value of the preceding numbered paragraph.
256
- if memo-style == "daf" {
257
- if nest_level > 0 {
258
- format-par(item_content, nest_level, level-counts, indent-fn, continuation: true)
259
- } else {
260
- item_content
261
- }
262
- } else if auto-numbering {
263
- format-par(item_content, nest_level, level-counts, indent-fn, continuation: true)
264
- } else if nest_level > 0 {
265
- format-par(item_content, nest_level - 1, level-counts, indent-fn, continuation: true)
266
- } else {
263
+ if memo-style == "daf" and nest_level == 0 {
267
264
  item_content
265
+ } else {
266
+ format-par(item_content, nest_level, level-counts, indent-fn, continuation: true)
268
267
  }
269
268
  } else if memo-style == "daf" {
270
269
  if nest_level > 0 {
@@ -278,37 +277,28 @@
278
277
  level-counts = reset-levels-from(level-counts, 0, max-levels)
279
278
  [#h(daf-paragraph.top-first-line-indent)#item_content]
280
279
  }
281
- } else if auto-numbering {
282
- if par_count > 1 {
283
- // Apply paragraph numbering per AFH 33-337 §2
284
- let par = format-par(item_content, nest_level, level-counts, indent-fn)
285
- level-counts.insert(str(nest_level), level-counts.at(str(nest_level)) + 1)
286
- level-counts = reset-levels-from(level-counts, nest_level + 1, max-levels)
287
- par
288
- } else {
289
- // AFH 33-337 §2: "A single paragraph is not numbered"
290
- item_content
291
- }
280
+ } else if par_count > 1 {
281
+ // Apply paragraph numbering per AFH 33-337 §2
282
+ let par = format-par(item_content, nest_level, level-counts, indent-fn)
283
+ level-counts.insert(str(nest_level), level-counts.at(str(nest_level)) + 1)
284
+ level-counts = reset-levels-from(level-counts, nest_level + 1, max-levels)
285
+ par
292
286
  } else {
293
- // Unnumbered mode: only explicitly nested items (enum/list) get numbered
294
- if nest_level > 0 {
295
- let effective_level = nest_level - 1
296
- let par = format-par(item_content, effective_level, level-counts, indent-fn)
297
- level-counts.insert(str(effective_level), level-counts.at(str(effective_level)) + 1)
298
- level-counts = reset-levels-from(level-counts, effective_level + 1, max-levels)
299
- par
300
- } else {
301
- // Base-level paragraphs are flush left with no numbering.
302
- // Reset all child level counters so subsequent list items restart at 1.
303
- level-counts = reset-levels-from(level-counts, 0, max-levels)
304
- item_content
305
- }
287
+ // AFH 33-337 §2: "A single paragraph is not numbered"
288
+ item_content
306
289
  }
307
290
  }
308
291
 
309
- // If this is the final item, apply AFH 33-337 §11 rule:
310
- // "Avoid dividing a paragraph of less than four lines between two pages"
311
- blank-line()
292
+ // Blank line between paragraphs. The header→body gap (i.e. before
293
+ // the first emitted paragraph) is the caller's responsibility —
294
+ // emitting it here would put the v() inside a nested
295
+ // `context { for … }` block, where it does not combine with the
296
+ // preceding header section's block-spacing the same way as a
297
+ // top-level blank-line() call.
298
+ // AFH 33-337 §11 rule applied below: "Avoid dividing a paragraph of
299
+ // less than four lines between two pages"
300
+ if any_emitted { blank-line() }
301
+ any_emitted = true
312
302
  if i == total_count {
313
303
  let available_width = page.width - spacing.margin * 2
314
304
 
@@ -335,7 +325,15 @@
335
325
  block(breakable: true)[#final_par]
336
326
  }
337
327
  } else {
338
- final_par
328
+ // Wrap every non-last emission in a plain block so the document-wide
329
+ // `set block(above: spacing.line)` rule contributes the same 0.5em
330
+ // gap above every paragraph, including the first (matching the
331
+ // single-paragraph case which always passes through the
332
+ // `i == total_count` branch) and middle paragraphs (so the gap
333
+ // between the 1st and 2nd doesn't shrink relative to the gap
334
+ // between the 2nd-to-last and last). Bare emission would skip
335
+ // `block.above` entirely and visibly compress the spacing.
336
+ block[#final_par]
339
337
  }
340
338
  }
341
339
  }
@@ -9,11 +9,7 @@
9
9
  // AFH 33-337 specifies precise spacing requirements throughout Chapter 14
10
10
 
11
11
  #let spacing = (
12
- // Legacy exported field retained for compatibility with downstream imports.
13
- // Internal blank-line spacing now uses measured line stride from utils.typ.
14
- vertical: 19.05pt,
15
12
  line: .5em, // Internal line spacing for readability (`par.leading`; gap between line boxes)
16
- line-height: .7em, // Base height of text lines for pagination estimates
17
13
  tab: 0.5in, // Tab stop for multi-column recipient alignment
18
14
  margin: 1in, // AFH 33-337 §4: "Use 1-inch margins on the left, right and bottom"
19
15
  )
@@ -63,11 +59,17 @@
63
59
  // AFH 33-337 §3: "Follow AFI 31-401, Information Security Program Management,
64
60
  // applicable executive orders and DoD guidance for the necessary markings on
65
61
  // classified correspondence."
66
- // Color values follow DoD standard classification marking colors
67
- // Source: https://security.stackexchange.com/questions/161829
62
+ // Color values follow DoD/CAPCO standard classification marking colors:
63
+ // - TOP SECRET: orange (#FF671F)
64
+ // - SECRET: red (#C8102E)
65
+ // - CONFIDENTIAL: black (per project style guide)
66
+ // - CUI: black (DoDM 5200.48; per project style guide)
67
+ // - UNCLASSIFIED: green (#007A33)
68
68
 
69
69
  #let CLASSIFICATION_COLORS = (
70
- "UNCLASSIFIED": rgb(0, 122, 51), // Forest green (#007A33)
71
- "SECRET": rgb(200, 16, 46), // Crimson red (#C8102E)
72
- "TOP SECRET": rgb(255, 103, 31), // Burnt orange (#FF671F)
70
+ "TOP SECRET": rgb(255, 103, 31),
71
+ "SECRET": rgb(200, 16, 46),
72
+ "CONFIDENTIAL": rgb(0, 0, 0),
73
+ "CUI": rgb(0, 0, 0),
74
+ "UNCLASSIFIED": rgb(0, 122, 51),
73
75
  )
@@ -24,8 +24,8 @@
24
24
  font_size: 12pt,
25
25
  memo_for_cols: 3,
26
26
  classification_level: none,
27
+ dissemination: none,
27
28
  footer_tag_line: none,
28
- auto_numbering: true,
29
29
  memo_style: "usaf",
30
30
  it,
31
31
  ) = {
@@ -38,6 +38,26 @@
38
38
  )
39
39
 
40
40
  let actual_date = if date == none { datetime.today() } else { date }
41
+
42
+ let classification_marking = if classification_level == none or type(classification_level) != str {
43
+ none
44
+ } else {
45
+ let base = classification_level.trim()
46
+ if base == "" {
47
+ none
48
+ } else {
49
+ let disp = if dissemination == none or type(dissemination) != str {
50
+ ""
51
+ } else {
52
+ dissemination.trim()
53
+ }
54
+ if disp != "" {
55
+ base + "//" + upper(disp)
56
+ } else {
57
+ base
58
+ }
59
+ }
60
+ }
41
61
  let classification_color = get-classification-level-color(classification_level)
42
62
 
43
63
  // Document-wide typography settings (inlined from configure())
@@ -68,20 +88,22 @@
68
88
  )
69
89
  }
70
90
 
71
- if classification_level != none {
91
+ if classification_marking != none {
72
92
  place(
73
93
  top + center,
74
94
  dy: 0.375in,
75
- text(12pt, font: DEFAULT_BODY_FONTS, fill: classification_color)[#strong(classification_level)],
95
+ text(12pt, font: DEFAULT_BODY_FONTS, fill: classification_color)[#strong(classification_marking)],
76
96
  )
77
97
  }
78
98
  },
79
99
  footer: {
80
- place(
81
- bottom + center,
82
- dy: -.375in,
83
- text(12pt, font: DEFAULT_BODY_FONTS, fill: classification_color)[#strong(classification_level)],
84
- )
100
+ if classification_marking != none {
101
+ place(
102
+ bottom + center,
103
+ dy: -.375in,
104
+ text(12pt, font: DEFAULT_BODY_FONTS, fill: classification_color)[#strong(classification_marking)],
105
+ )
106
+ }
85
107
 
86
108
  if not falsey(footer_tag_line) {
87
109
  place(
@@ -107,14 +129,11 @@
107
129
  // Since we have a 1-inch top margin, we need (1.75in - margin) vertical space
108
130
  v(1.75in - spacing.margin)
109
131
 
110
- // Measure and cache body line stride once for downstream spacing logic.
132
+ // Measure and cache body line stride once for body line-count heuristics.
111
133
  context {
112
134
  let one-line = measure(par(spacing: 0pt)[x]).height
113
135
  let line-stride = measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
114
- let em-size = measure(box(width: 1em)[]).width
115
- let legacy-scaled = spacing.vertical * (em-size / 12pt)
116
136
  LINE_STRIDE.update(line-stride)
117
- BLANK_LINE_STEP.update(calc.max(line-stride, legacy-scaled))
118
137
  }
119
138
 
120
139
  metadata((
@@ -123,7 +142,6 @@
123
142
  original_from: first-or-value(memo_from),
124
143
  body_font: body_font,
125
144
  font_size: font_size,
126
- auto_numbering: auto_numbering,
127
145
  memo_style: memo_style,
128
146
  ))
129
147
 
@@ -133,5 +151,10 @@
133
151
  render-subject-section(subject)
134
152
  render-references-section(references)
135
153
 
154
+ // AFH 33-337: "Begin text on second line below subject/references".
155
+ // Emitted here (not inside body.typ) so the v() lands at the same lexical
156
+ // level as the preceding header sections and combines correctly with their
157
+ // block spacing.
158
+ blank-line()
136
159
  it
137
160
  }
@@ -41,7 +41,7 @@
41
41
  }
42
42
 
43
43
 
44
- let actual_date = if date == none { datetime.today() } else { date }
44
+ let actual_date = date
45
45
  let ind_from = first-or-value(from)
46
46
  let ind_for = to
47
47
 
@@ -67,7 +67,7 @@
67
67
  blank-line()
68
68
  grid(
69
69
  columns: (auto, 1fr),
70
- ind_from, align(right)[#display-date(actual_date, memo-style: memo-style)],
70
+ ind_from, align(right)[#if actual_date != none { display-date(actual_date, memo-style: memo-style) }],
71
71
  )
72
72
 
73
73
  blank-line()
@@ -79,7 +79,7 @@
79
79
  blank-line()
80
80
  grid(
81
81
  columns: (auto, 1fr),
82
- [#indorsement_label, #ind_from], align(right)[#display-date(actual_date, memo-style: memo-style)],
82
+ [#indorsement_label, #ind_from], align(right)[#if actual_date != none { display-date(actual_date, memo-style: memo-style) }],
83
83
  )
84
84
 
85
85
  blank-line()
@@ -18,15 +18,10 @@
18
18
  /// of the memorandum. Automatically detects single vs. multiple paragraphs
19
19
  /// to comply with AFH 33-337 numbering requirements.
20
20
  ///
21
- /// When `auto_numbering` is false (set in frontmatter), base-level paragraphs
22
- /// render flush left without numbering. Only explicitly numbered or bulleted
23
- /// items enter the numbering hierarchy.
24
- ///
25
21
  /// - content (content): The body content to render
26
22
  /// -> content
27
23
  #let mainmatter(it) = context {
28
24
  let config = query(metadata).last().value
29
- let auto-numbering = config.at("auto_numbering", default: true)
30
25
  let memo-style = config.at("memo_style", default: "usaf")
31
- render-body(it, auto-numbering: auto-numbering, memo-style: memo-style)
26
+ render-body(it, memo-style: memo-style)
32
27
  }
@@ -142,29 +142,54 @@
142
142
  // SIGNATURE BLOCK
143
143
  // =============================================================================
144
144
  // AFH 33-337 "Signature Block": "Start the signature block on the fifth line below
145
- // the last line of text and 4.5 inches from the left edge of the page"
145
+ // the last line of text and 4.5 inches from the left edge of the page or three
146
+ // spaces to the right of page center"
146
147
  // AFH 33-337 "Do not place the signature element on a continuation page by itself"
148
+ // AFH 33-337 long-name example: "Signature block adjusted to the left" when a
149
+ // long name would otherwise exceed the right margin.
147
150
 
148
151
  #let render-signature-block(signature-lines, signature-blank-lines: 4) = {
149
152
  signature-lines = ensure-array(signature-lines)
150
- // AFH 33-337: "The signature block is never on a page by itself"
151
- // Note: Perfect enforcement isn't feasible without over-engineering
152
- // We use weak: false spacing and breakable: false to discourage orphaning
153
- // AFH 33-337: "fifth line below" = 4 blank lines between text and signature block
154
- blank-lines(signature-blank-lines, weak: false)
155
- block(breakable: false)[
156
- #align(left)[
157
- // AFH 33-337: "4.5 inches from the left edge of the page"
158
- // We use (4.5in - margin) because Typst's pad() is relative to the text area, not page edge
159
- #pad(left: 4.5in - spacing.margin)[
160
- #text(hyphenate: false)[
161
- #for line in signature-lines {
162
- par(hanging-indent: 4 * 0.5em, line)
163
- }
153
+ // AFH 33-337: "fifth line below" = 4 blank lines between text and signature block.
154
+ // breakable: false discourages orphaning the signature block onto a page by itself.
155
+ blank-lines(signature-blank-lines)
156
+ // AFH 33-337 allows two equivalent anchors: 4.5in from the left edge, or three
157
+ // spaces right of page center. On 8.5in stock these coincide (page center =
158
+ // 4.25in; three TNR-12pt spaces ≈ 0.25in), so we use 4.5in as the canonical
159
+ // anchor. pad() is relative to the text area, hence (4.5in - margin).
160
+ let default-pad = 4.5in - spacing.margin
161
+ context {
162
+ // Measure each line at its rendered settings to detect long-name overflow.
163
+ let body-width = page.width - 2 * spacing.margin
164
+ let widest = 0pt
165
+ for line in signature-lines {
166
+ let w = measure(text(hyphenate: false, line)).width
167
+ if w > widest { widest = w }
168
+ }
169
+ // If the widest line would overflow the right margin at the standard
170
+ // anchor, shift the block left just enough to fit. Clamp at 0 so the
171
+ // block never crosses the left margin.
172
+ let available = body-width - default-pad
173
+ let left-pad = if widest > available {
174
+ let shifted = body-width - widest
175
+ if shifted < 0pt { 0pt } else { shifted }
176
+ } else {
177
+ default-pad
178
+ }
179
+ block(breakable: false)[
180
+ #align(left)[
181
+ #pad(left: left-pad)[
182
+ #text(hyphenate: false)[
183
+ #for line in signature-lines {
184
+ // AFH 33-337: "indent the next line to begin under the third character
185
+ // of the line above" — 2-character indent ≈ 1em in Times New Roman 12pt
186
+ par(hanging-indent: .5em, line)
187
+ }
188
+ ]
164
189
  ]
165
190
  ]
166
191
  ]
167
- ]
192
+ }
168
193
  }
169
194
 
170
195
  // =============================================================================
@@ -181,7 +206,10 @@
181
206
  action in ("none", "undecided", "approve", "disapprove"),
182
207
  message: "action must be \"none\", \"undecided\", \"approve\", or \"disapprove\"",
183
208
  )
184
- blank-line()
209
+ // No leading blank-line: the caller (indorsement.typ) already emits the
210
+ // header→content gap once. The action line's `block(sticky: true)`
211
+ // additionally inherits `block.above: spacing.line` so the visual gap
212
+ // above matches the gap above a body's first paragraph.
185
213
  // Circle the selected option using a box with rounded corners
186
214
  // Use baseline parameter to maintain vertical text alignment
187
215
  let approve-text = if action == "approve" {
@@ -202,6 +230,10 @@
202
230
  // using the same sticky-block pattern that body.typ applies to the last
203
231
  // paragraph, per AFH 33-337 §11 orphan-prevention rules.
204
232
  block(sticky: true)[#approve-text / #disapprove-text]
233
+ // Trailing blank-line so the body's first paragraph (or signature block,
234
+ // when the body is empty) sits one line below the action line, mirroring
235
+ // the gap above it.
236
+ blank-line()
205
237
  }
206
238
 
207
239
  // =============================================================================
@@ -14,46 +14,37 @@
14
14
 
15
15
  #import "config.typ": CLASSIFICATION_COLORS, counters, paragraph-config, spacing
16
16
 
17
- // Shared measured line-stride cache used by blank-line spacing and body
18
- // line-count heuristics. Value is a `length` set once in `frontmatter`.
17
+ // Shared measured line-stride cache used by body line-count heuristics.
18
+ // Value is a `length` set once in `frontmatter`.
19
19
  #let LINE_STRIDE = state("LINE_STRIDE")
20
- // Shared blank-line step cache used by structural spacing.
21
- #let BLANK_LINE_STEP = state("BLANK_LINE_STEP")
22
20
 
23
21
  /// Creates vertical spacing equivalent to multiple blank lines.
24
22
  ///
25
- /// Each step matches one row of the body paragraph line grid.
23
+ /// Adds `count` wrapped-line strides on top of the natural inter-paragraph
24
+ /// gap, so a blank line occupies exactly the same vertical space as a line
25
+ /// produced by natural paragraph wrapping. The stride is measured from
26
+ /// `LINE_STRIDE` (cached in `frontmatter`) and falls back to an inline
27
+ /// measurement when the cache is unset.
26
28
  ///
27
29
  /// - count (int): Number of blank lines to create
28
- /// - weak (bool): Whether spacing can be compressed at page breaks
29
30
  /// -> content
30
- #let blank-lines(count, weak: true) = {
31
- if count == 0 {
32
- v(0em, weak: weak)
33
- } else {
34
- context {
35
- let measured-stride = {
36
- let one-line = measure(par(spacing: 0pt)[x]).height
37
- measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
38
- }
39
- // Scale legacy visual step with current font size:
40
- // 19.05pt was tuned for 12pt body text.
41
- let em-size = measure(box(width: 1em)[]).width
42
- let legacy-scaled = spacing.vertical * (em-size / 12pt)
43
- let fallback-step = calc.max(measured-stride, legacy-scaled)
44
- let cached-step = BLANK_LINE_STEP.get()
45
- let step = if cached-step != none { cached-step } else { fallback-step }
46
- v(step * count, weak: weak)
31
+ #let blank-lines(count) = {
32
+ if count <= 0 { return }
33
+ context {
34
+ let stride = LINE_STRIDE.get()
35
+ if stride == none {
36
+ let one-line = measure(par(spacing: 0pt)[x]).height
37
+ stride = measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
47
38
  }
39
+ v(stride * count)
48
40
  }
49
41
  }
50
42
 
51
43
  /// Creates vertical spacing equivalent to one blank line.
52
44
  /// Convenience function for single line spacing.
53
45
  ///
54
- /// - weak (bool): Whether spacing can be compressed at page breaks
55
46
  /// -> content
56
- #let blank-line(weak: true) = blank-lines(1, weak: weak)
47
+ #let blank-line() = blank-lines(1)
57
48
 
58
49
  // =============================================================================
59
50
  // GENERAL UTILITY FUNCTIONS
@@ -123,8 +114,8 @@
123
114
 
124
115
  /// Gets the banner color for a classification marking.
125
116
  ///
126
- /// Matches when `level` (trimmed) starts with a known prefix: TOP SECRET, SECRET, or UNCLASSIFIED.
127
- /// Otherwise returns black.
117
+ /// Matches when `level` (trimmed) starts with a known prefix: TOP SECRET, SECRET,
118
+ /// CONFIDENTIAL, CUI, or UNCLASSIFIED. Otherwise returns black.
128
119
  ///
129
120
  /// - level (str): Marking string shown in header/footer
130
121
  /// -> color
@@ -133,8 +124,8 @@
133
124
  return rgb(0, 0, 0)
134
125
  }
135
126
  let s = level.trim()
136
- // "TOP SECRET" before "SECRET" so the full phrase matches first.
137
- let level-order = ("TOP SECRET", "SECRET", "UNCLASSIFIED")
127
+ // Longest-prefix-first so e.g. "TOP SECRET" wins over "SECRET".
128
+ let level-order = ("TOP SECRET", "SECRET", "CONFIDENTIAL", "CUI", "UNCLASSIFIED")
138
129
  for base-level in level-order {
139
130
  if s.starts-with(base-level) {
140
131
  return CLASSIFICATION_COLORS.at(base-level)
@@ -36,6 +36,8 @@
36
36
  // Optional classification level
37
37
  ..if "classification" in data { (classification_level: data.classification) },
38
38
 
39
+ ..if "dissemination" in data { (dissemination: data.dissemination) },
40
+
39
41
  // USAF vs DAF memorandum style (date format, body indentation)
40
42
  memo_style: data.at("memo_style", default: "usaf"),
41
43