@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 +1 -1
- package/package.json +3 -3
- package/quills/usaf_memo/0.2.0/Quill.yaml +21 -6
- package/quills/usaf_memo/0.2.0/example.md +2 -2
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/body.typ +40 -42
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/config.typ +11 -9
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +36 -13
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +3 -3
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +1 -6
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +49 -17
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/src/utils.typ +20 -29
- package/quills/usaf_memo/0.2.0/plate.typ +2 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@airmark/quiver",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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 (
|
|
157
|
-
type:
|
|
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
|
-
|
|
178
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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 =
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
//
|
|
294
|
-
|
|
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
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
"
|
|
71
|
-
"SECRET": rgb(200, 16, 46),
|
|
72
|
-
"
|
|
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
|
|
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(
|
|
95
|
+
text(12pt, font: DEFAULT_BODY_FONTS, fill: classification_color)[#strong(classification_marking)],
|
|
76
96
|
)
|
|
77
97
|
}
|
|
78
98
|
},
|
|
79
99
|
footer: {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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: "
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
// AFH 33-337
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
18
|
-
//
|
|
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
|
-
///
|
|
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
|
|
31
|
-
if count
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
let
|
|
36
|
-
|
|
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(
|
|
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,
|
|
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"
|
|
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
|
|