@airmark/quiver 0.31.0 → 0.32.1
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 +4 -4
- package/quills/af4141/0.1.0/plate.typ +6 -14
- package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/lib.typ +1 -1
- package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/utils.typ +9 -10
- package/quills/afmc_moa/0.0.1/plate.typ +8 -3
- package/quills/usaf_memo/0.3.0/Quill.yaml +47 -59
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +94 -11
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +13 -5
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +1 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +28 -19
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +37 -30
- package/quills/usaf_memo/0.3.0/plate.typ +78 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@airmark/quiver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"description": "Airmark Quiver — Air Force and DAF document Quills for Quillmark.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"node": ">=24"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@quillmark/quiver": "0.
|
|
20
|
-
"@quillmark/wasm": "^0.
|
|
21
|
-
"quillkit": "0.3
|
|
19
|
+
"@quillmark/quiver": "0.26.0",
|
|
20
|
+
"@quillmark/wasm": "^0.109.1",
|
|
21
|
+
"quillkit": "0.5.3"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "quillkit test",
|
|
@@ -3,23 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
#set text(font: "NimbusRomNo9L")
|
|
5
5
|
|
|
6
|
-
// A `type: date` field
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// is still accepted. Missing / blank dates arrive as `none`; empty strings may
|
|
11
|
-
// also appear from input.
|
|
6
|
+
// A `type: date` field arrives as a native `datetime`, blank as `none`. The
|
|
7
|
+
// form's cells are strings, so a date is formatted here rather than placed as
|
|
8
|
+
// ink: `display` would give the glyphs a schema address, but the form draws
|
|
9
|
+
// each cell into a fixed box it builds itself, which takes a `str`.
|
|
12
10
|
#let form-cell(v) = {
|
|
13
11
|
let pattern = "[month padding:none]/[day padding:none]/[year]"
|
|
14
12
|
if v == none { "" }
|
|
15
|
-
else if type(v) ==
|
|
16
|
-
else
|
|
17
|
-
(v.display)(pattern)
|
|
18
|
-
} else if type(v) == datetime {
|
|
19
|
-
v.display(pattern)
|
|
20
|
-
} else {
|
|
21
|
-
str(v)
|
|
22
|
-
}
|
|
13
|
+
else if type(v) == datetime { v.display(pattern) }
|
|
14
|
+
else { str(v) }
|
|
23
15
|
}
|
|
24
16
|
|
|
25
17
|
// Column order within each 7-field row group
|
|
@@ -38,4 +38,4 @@
|
|
|
38
38
|
#import "mainmatter.typ": mainmatter
|
|
39
39
|
#import "attachment.typ": render-attachment-a as attachment, render-attachment-card as attachment-card
|
|
40
40
|
#import "backmatter.typ": backmatter
|
|
41
|
-
#import "utils.typ": trim-inline
|
|
41
|
+
#import "utils.typ": date-pattern, trim-inline
|
|
@@ -82,23 +82,22 @@
|
|
|
82
82
|
if joined == none { [] } else { joined }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/// The date pattern this package prints ("22 July 2026"). Exported so a caller
|
|
86
|
+
/// that pre-formats a date matches it instead of restating it.
|
|
87
|
+
#let date-pattern = "[day padding:none] [month repr:long] [year]"
|
|
88
|
+
|
|
85
89
|
/// Format a date value for inline display ("22 July 2026").
|
|
86
90
|
///
|
|
87
|
-
///
|
|
88
|
-
///
|
|
89
|
-
/// - datetime:
|
|
90
|
-
/// - dict: a Quillmark `date`/`datetime` field lowers to a click-to-edit value
|
|
91
|
-
/// object `{ value: datetime, display: closure }`; `display` is a stored
|
|
92
|
-
/// closure, so it must be called through parentheses — a dict has no
|
|
93
|
-
/// `.display` method.
|
|
91
|
+
/// - str, content: shown as-is (a fixed placeholder, or ink the caller already
|
|
92
|
+
/// formatted through [`date-pattern`]).
|
|
93
|
+
/// - datetime: formatted to [`date-pattern`].
|
|
94
94
|
#let format-date(d) = {
|
|
95
95
|
if d == none {
|
|
96
96
|
""
|
|
97
|
-
} else if type(d)
|
|
97
|
+
} else if type(d) in (str, content) {
|
|
98
98
|
d
|
|
99
99
|
} else {
|
|
100
|
-
|
|
101
|
-
if type(d) == datetime { d.display(pattern) } else { (d.display)(pattern) }
|
|
100
|
+
d.display(date-pattern)
|
|
102
101
|
}
|
|
103
102
|
}
|
|
104
103
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#import "@local/quillmark-helper:0.1.0": data, signature-field
|
|
1
|
+
#import "@local/quillmark-helper:0.1.0": data, display, signature-field
|
|
2
2
|
#import "@local/typst-afmc-moa:0.1.0": (
|
|
3
|
-
attachment, attachment-card, backmatter, frontmatter, mainmatter, trim-inline,
|
|
3
|
+
attachment, attachment-card, backmatter, date-pattern, frontmatter, mainmatter, trim-inline,
|
|
4
4
|
)
|
|
5
5
|
|
|
6
6
|
// Every text field is `plaintext`/`richtext`, so it arrives as content carrying
|
|
@@ -34,7 +34,12 @@
|
|
|
34
34
|
|
|
35
35
|
first_party_signatory: text-field("first_party_signatory"),
|
|
36
36
|
second_party_signatory: text-field("second_party_signatory"),
|
|
37
|
-
|
|
37
|
+
// `data.mid_point_review_due_date` is the native `datetime` and would render
|
|
38
|
+
// identically, but its ink would be born inside the package and carry no
|
|
39
|
+
// schema address. `display` places the field's content projection instead, so
|
|
40
|
+
// the printed date stays click-to-edit; a blank one yields `none`, which
|
|
41
|
+
// `format-date` renders as nothing.
|
|
42
|
+
mid_point_review_due_date: display("mid_point_review_due_date", date-pattern),
|
|
38
43
|
)
|
|
39
44
|
|
|
40
45
|
// ── Frontmatter: page setup, title block, intro paragraph ──────────────────
|
|
@@ -15,6 +15,11 @@ main:
|
|
|
15
15
|
The first paragraph. Top-level paragraphs are auto-numbered; do not add manual numbering.
|
|
16
16
|
|
|
17
17
|
- Nested bullets are automatically lettered.
|
|
18
|
+
|
|
19
|
+
Lines that take no number, letter, or bullet — a roster of names, an address — go in a block quote, typeset as written. End a line with a backslash to break it.
|
|
20
|
+
|
|
21
|
+
> FIRST M. LAST, Maj, USAF\
|
|
22
|
+
> SECOND N. LAST, Capt, USAF
|
|
18
23
|
fields:
|
|
19
24
|
memo_for:
|
|
20
25
|
type: array
|
|
@@ -46,12 +51,12 @@ main:
|
|
|
46
51
|
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)."
|
|
47
52
|
|
|
48
53
|
subject:
|
|
49
|
-
type:
|
|
54
|
+
type: richtext
|
|
50
55
|
inline: true
|
|
51
56
|
example: Subject of the Memorandum
|
|
52
57
|
ui:
|
|
53
58
|
group: addressing
|
|
54
|
-
description: Be brief and clear. Capitalize the first letter of each word except articles, prepositions, and conjunctions. Include suspense dates in parentheses if applicable.
|
|
59
|
+
description: "Be brief and clear. Capitalize the first letter of each word except articles, prepositions, and conjunctions. Include suspense dates in parentheses if applicable. Italicize publication titles with *asterisks* (standard Markdown emphasis), as in the References field."
|
|
55
60
|
|
|
56
61
|
signature_block:
|
|
57
62
|
type: array
|
|
@@ -66,6 +71,14 @@ main:
|
|
|
66
71
|
compact: true
|
|
67
72
|
description: "Line 1: Name in UPPERCASE as signed, grade, and service. Line 2: Duty title."
|
|
68
73
|
|
|
74
|
+
date:
|
|
75
|
+
type: date
|
|
76
|
+
default: ""
|
|
77
|
+
ui:
|
|
78
|
+
group: addressing
|
|
79
|
+
compact: true
|
|
80
|
+
description: YYYY-MM-DD. Leave blank to use today's date.
|
|
81
|
+
|
|
69
82
|
letterhead_title:
|
|
70
83
|
type: array
|
|
71
84
|
items:
|
|
@@ -122,6 +135,28 @@ main:
|
|
|
122
135
|
group: classification
|
|
123
136
|
compact: true
|
|
124
137
|
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."
|
|
138
|
+
variants:
|
|
139
|
+
CUI:
|
|
140
|
+
controlled_by:
|
|
141
|
+
type: plaintext
|
|
142
|
+
inline: true
|
|
143
|
+
example: SAF/AA
|
|
144
|
+
description: "Office or organization that designated this information as CUI. Required by DoDM 5200.48."
|
|
145
|
+
poc:
|
|
146
|
+
type: plaintext
|
|
147
|
+
inline: true
|
|
148
|
+
example: "Capt J. Smith, DSN 555-1234, john.smith@us.af.mil"
|
|
149
|
+
description: "Point of contact for CUI questions. Required by DoDM 5200.48."
|
|
150
|
+
category:
|
|
151
|
+
type: plaintext
|
|
152
|
+
inline: true
|
|
153
|
+
default: ""
|
|
154
|
+
description: "CUI category from the DoD CUI Registry (e.g., 'Privacy/MED', 'CTI', 'PRVCY'). Leave blank to omit."
|
|
155
|
+
limited_dissemination:
|
|
156
|
+
type: plaintext
|
|
157
|
+
inline: true
|
|
158
|
+
default: ""
|
|
159
|
+
description: "Limited dissemination control shown in the indicator block (e.g., 'FEDONLY', 'NOFORN'). Independent from the banner suffix."
|
|
125
160
|
|
|
126
161
|
dissemination:
|
|
127
162
|
type: plaintext
|
|
@@ -132,42 +167,6 @@ main:
|
|
|
132
167
|
compact: true
|
|
133
168
|
description: "Banner dissemination suffix appended after double slash (e.g. 'NF' renders as CUI//NF in the header/footer). Leave blank for no suffix."
|
|
134
169
|
|
|
135
|
-
cui_controlled_by:
|
|
136
|
-
type: plaintext
|
|
137
|
-
inline: true
|
|
138
|
-
default: ""
|
|
139
|
-
ui:
|
|
140
|
-
group: classification
|
|
141
|
-
compact: true
|
|
142
|
-
description: "Office or organization that designated this information as CUI (e.g., 'SAF/AA'). Required by DoDM 5200.48 when classification is CUI."
|
|
143
|
-
|
|
144
|
-
cui_category:
|
|
145
|
-
type: plaintext
|
|
146
|
-
inline: true
|
|
147
|
-
default: ""
|
|
148
|
-
ui:
|
|
149
|
-
group: classification
|
|
150
|
-
compact: true
|
|
151
|
-
description: "CUI category from the DoD CUI Registry (e.g., 'Privacy/MED', 'CTI', 'PRVCY'). Leave blank to omit."
|
|
152
|
-
|
|
153
|
-
cui_limited_dissemination:
|
|
154
|
-
type: plaintext
|
|
155
|
-
inline: true
|
|
156
|
-
default: ""
|
|
157
|
-
ui:
|
|
158
|
-
group: classification
|
|
159
|
-
compact: true
|
|
160
|
-
description: "Limited dissemination control shown in the indicator block (e.g., 'FEDONLY', 'NOFORN'). Independent from the banner suffix above."
|
|
161
|
-
|
|
162
|
-
cui_poc:
|
|
163
|
-
type: plaintext
|
|
164
|
-
inline: true
|
|
165
|
-
default: ""
|
|
166
|
-
ui:
|
|
167
|
-
group: classification
|
|
168
|
-
compact: true
|
|
169
|
-
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."
|
|
170
|
-
|
|
171
170
|
references:
|
|
172
171
|
type: array
|
|
173
172
|
items:
|
|
@@ -209,14 +208,14 @@ main:
|
|
|
209
208
|
attachments:
|
|
210
209
|
type: array
|
|
211
210
|
items:
|
|
212
|
-
type:
|
|
211
|
+
type: richtext
|
|
213
212
|
inline: true
|
|
214
213
|
default: []
|
|
215
214
|
example:
|
|
216
215
|
- Attachment description, YYYY MMM DD
|
|
217
216
|
ui:
|
|
218
217
|
group: additional
|
|
219
|
-
description: List attachments in the order they are mentioned in the memo. Briefly describe each; do not use 'as stated' or abbreviations.
|
|
218
|
+
description: "List attachments in the order they are mentioned in the memo. Briefly describe each; do not use 'as stated' or abbreviations. Italicize publication titles with *asterisks* (standard Markdown emphasis), as in the References field."
|
|
220
219
|
|
|
221
220
|
memo_style:
|
|
222
221
|
type: enum
|
|
@@ -237,14 +236,6 @@ main:
|
|
|
237
236
|
compact: true
|
|
238
237
|
description: Body text size in points; decimals allowed (e.g. 11.5).
|
|
239
238
|
|
|
240
|
-
date:
|
|
241
|
-
type: date
|
|
242
|
-
default: ""
|
|
243
|
-
ui:
|
|
244
|
-
group: additional
|
|
245
|
-
compact: true
|
|
246
|
-
description: YYYY-MM-DD. Leave blank to use today's date.
|
|
247
|
-
|
|
248
239
|
card_kinds:
|
|
249
240
|
indorsement:
|
|
250
241
|
description: Chain of routing endorsements. Each endorsement block adds an official response or forwarding action to the original memo.
|
|
@@ -280,6 +271,13 @@ card_kinds:
|
|
|
280
271
|
group: addressing
|
|
281
272
|
compact: true
|
|
282
273
|
description: "Line 1: Name in UPPERCASE as signed, grade, and service. Line 2: Duty title."
|
|
274
|
+
date:
|
|
275
|
+
type: date
|
|
276
|
+
default: ""
|
|
277
|
+
ui:
|
|
278
|
+
group: addressing
|
|
279
|
+
compact: true
|
|
280
|
+
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 fillable PDF text field for the endorser to type the date into when signing."
|
|
283
281
|
format:
|
|
284
282
|
type: enum
|
|
285
283
|
values:
|
|
@@ -297,18 +295,8 @@ card_kinds:
|
|
|
297
295
|
- undecided
|
|
298
296
|
- approve
|
|
299
297
|
- disapprove
|
|
300
|
-
- undecided_concur
|
|
301
|
-
- concur
|
|
302
|
-
- nonconcur
|
|
303
|
-
default: ""
|
|
304
|
-
ui:
|
|
305
|
-
group: additional
|
|
306
|
-
compact: true
|
|
307
|
-
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)."
|
|
308
|
-
date:
|
|
309
|
-
type: date
|
|
310
298
|
default: ""
|
|
311
299
|
ui:
|
|
312
300
|
group: additional
|
|
313
301
|
compact: true
|
|
314
|
-
description: "
|
|
302
|
+
description: "Decision taken by the endorser, shown as an either/or line with the choice underlined and the rejected option struck out. Leave blank to hide the line entirely. The wording is automatic: the last indorsement is the approval authority and reads Approve/Disapprove, while every earlier one is a coordinating official and reads Concur/Nonconcur. Use 'undecided' to print the pair unmarked for the endorser to mark by hand."
|
|
@@ -60,6 +60,25 @@
|
|
|
60
60
|
level-counts
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/// The horizontal offset at which a paragraph's *text* sits: its indent plus
|
|
64
|
+
/// the width of the number label that precedes it.
|
|
65
|
+
///
|
|
66
|
+
/// This is where a later block of the same paragraph — a continuation, a block
|
|
67
|
+
/// quote — lines up, so the two callers share one measurement rather than each
|
|
68
|
+
/// spelling it. A top-level paragraph carries no indent and its label is the
|
|
69
|
+
/// left margin itself, so the offset is zero.
|
|
70
|
+
///
|
|
71
|
+
/// - level (int): Nesting level (0-based)
|
|
72
|
+
/// - level-counts (dictionary): Current counter values per level
|
|
73
|
+
/// - indent-fn (function): `(level, level-counts) -> length`
|
|
74
|
+
/// -> length
|
|
75
|
+
#let paragraph-text-offset(level, level-counts, indent-fn) = {
|
|
76
|
+
if level <= 0 { return 0pt }
|
|
77
|
+
let current-value = level-counts.at(str(level), default: 1)
|
|
78
|
+
let number-text = numbering(get-paragraph-numbering-format(level), current-value)
|
|
79
|
+
indent-fn(level, level-counts) + measure([#number-text#" "]).width
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
/// Formats a paragraph (or continuation) with a given indent strategy.
|
|
64
83
|
///
|
|
65
84
|
/// - body (content): Paragraph content
|
|
@@ -69,12 +88,13 @@
|
|
|
69
88
|
/// - continuation (bool): If true, adds number-label width to alignment
|
|
70
89
|
/// -> content
|
|
71
90
|
#let format-par(body, level, level-counts, indent-fn, continuation: false) = {
|
|
72
|
-
let indent-width = indent-fn(level, level-counts)
|
|
73
91
|
if continuation {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
92
|
+
// Offset the first line only, which is how the numbered paragraph this
|
|
93
|
+
// block continues is set: its own wrapped lines return to the left margin
|
|
94
|
+
// too, so the two read as one paragraph.
|
|
95
|
+
[#h(paragraph-text-offset(level, level-counts, indent-fn))#body]
|
|
77
96
|
} else {
|
|
97
|
+
let indent-width = indent-fn(level, level-counts)
|
|
78
98
|
let current-value = level-counts.at(str(level), default: 1)
|
|
79
99
|
let number-text = numbering(get-paragraph-numbering-format(level), current-value)
|
|
80
100
|
[#h(indent-width)#number-text#" "#body]
|
|
@@ -145,6 +165,38 @@
|
|
|
145
165
|
})
|
|
146
166
|
t
|
|
147
167
|
}
|
|
168
|
+
// Collect block quotes — captured as-is, with paragraph processing kept
|
|
169
|
+
// out of them. AFH 33-337 numbers paragraphs and letters subparagraphs, and
|
|
170
|
+
// a body sometimes has to hold lines that are neither: a roster of names, a
|
|
171
|
+
// quoted passage, an address. The block quote is where an author says so —
|
|
172
|
+
// its content is emitted verbatim, so nothing inside it takes a number, a
|
|
173
|
+
// letter, or a bullet.
|
|
174
|
+
//
|
|
175
|
+
// Returning `none` rather than the quote is what makes that true: the
|
|
176
|
+
// quote's own paragraphs are never laid out here, so the `show par` above
|
|
177
|
+
// never sees them and cannot number them. The buffered body is emitted in
|
|
178
|
+
// the second pass, where these show rules are out of scope. Typst's own
|
|
179
|
+
// block-quote framing (padding, attribution) is dropped with the element;
|
|
180
|
+
// what reaches the page is the author's lines and nothing else.
|
|
181
|
+
//
|
|
182
|
+
// The nesting level rides along so a quote inside a list item lines up with
|
|
183
|
+
// that item's text (see the emission below). @quillmark/wasm 0.109.0 is
|
|
184
|
+
// what makes that level trustworthy: before it, a container inside a list
|
|
185
|
+
// item opened at column 0 and terminated the list, so a quote's siblings
|
|
186
|
+
// came back as top-level paragraphs (#123). It now nests, and this quill
|
|
187
|
+
// stopped declining the construct with it.
|
|
188
|
+
show quote.where(block: true): q => context {
|
|
189
|
+
let nest_level = NEST_DOWN.get().at(0) - NEST_UP.get().at(0)
|
|
190
|
+
PAR_BUFFER.update(pars => {
|
|
191
|
+
pars.push((
|
|
192
|
+
content: q.body,
|
|
193
|
+
nest_level: nest_level,
|
|
194
|
+
kind: "quote",
|
|
195
|
+
))
|
|
196
|
+
pars
|
|
197
|
+
})
|
|
198
|
+
none
|
|
199
|
+
}
|
|
148
200
|
{
|
|
149
201
|
show heading: h => {
|
|
150
202
|
IS_HEADING.update(true)
|
|
@@ -194,9 +246,9 @@
|
|
|
194
246
|
// Second pass: consume par buffer
|
|
195
247
|
//
|
|
196
248
|
// PAR_BUFFER item dictionary layout:
|
|
197
|
-
// item.content — the paragraph body or
|
|
249
|
+
// item.content — the paragraph body, table element, or block-quote body
|
|
198
250
|
// item.nest_level — nesting depth (−1 for tables)
|
|
199
|
-
// item.kind — "par", "heading", "table", or "
|
|
251
|
+
// item.kind — "par", "heading", "table", "continuation", or "quote"
|
|
200
252
|
context {
|
|
201
253
|
let heading_buffer = none
|
|
202
254
|
let heading_level = 0
|
|
@@ -230,13 +282,15 @@
|
|
|
230
282
|
// A buffered heading runs into this element only when the two belong to
|
|
231
283
|
// the same item: a later block of this list item ("continuation"), or,
|
|
232
284
|
// 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),
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
285
|
+
// a table (nest_level −1, so the level test alone excludes it), a block
|
|
286
|
+
// quote (verbatim: a heading prepended to it would be ink the author did
|
|
287
|
+
// not put inside the quote), and another heading all fail the test, and
|
|
288
|
+
// each would otherwise carry the heading's text somewhere it was not
|
|
289
|
+
// authored. Those emit the heading on its own line, the treatment a
|
|
290
|
+
// heading before a table takes.
|
|
237
291
|
if heading_buffer != none {
|
|
238
292
|
let runs_in = (
|
|
239
|
-
kind
|
|
293
|
+
kind not in ("heading", "quote")
|
|
240
294
|
and item.nest_level == heading_level
|
|
241
295
|
and (heading_level == 0 or kind == "continuation")
|
|
242
296
|
)
|
|
@@ -269,6 +323,35 @@
|
|
|
269
323
|
let final_par = {
|
|
270
324
|
if kind == "table" {
|
|
271
325
|
render-memo-table(item_content)
|
|
326
|
+
} else if kind == "quote" {
|
|
327
|
+
// A block quote is the body's unlabeled block: no number, no letter,
|
|
328
|
+
// no bullet — the author's lines as written. It is placed, not
|
|
329
|
+
// processed, which is what lets a memorandum carry a roster of names,
|
|
330
|
+
// an address, or a quoted passage without AFH 33-337 numbering
|
|
331
|
+
// claiming the first line of it.
|
|
332
|
+
//
|
|
333
|
+
// Two things are imposed, and both serve that reading.
|
|
334
|
+
//
|
|
335
|
+
// Where the block starts: at the offset where the text of the
|
|
336
|
+
// paragraph it sits in starts, so a quote inside a subparagraph hangs
|
|
337
|
+
// under that subparagraph's text instead of falling back to the left
|
|
338
|
+
// margin. `pad` shifts the whole block, where a continuation takes a
|
|
339
|
+
// first-line `h(..)`: a quote is kept for its line structure, and
|
|
340
|
+
// lines that wrapped back to the margin would read as a block other
|
|
341
|
+
// than the one authored. Top level offsets by 0pt — flush at the
|
|
342
|
+
// margin, where an unnumbered line belongs.
|
|
343
|
+
//
|
|
344
|
+
// How its own paragraphs are spaced: `par.leading` and `par.spacing`
|
|
345
|
+
// are both half an em in a memorandum, so a paragraph break inside
|
|
346
|
+
// the quote would land on the page as an ordinary line break and read
|
|
347
|
+
// as one block where the author wrote two. A blank line — what the
|
|
348
|
+
// body puts between its own paragraphs — keeps that break visible.
|
|
349
|
+
let quoted = {
|
|
350
|
+
set par(spacing: spacing.line + line-stride())
|
|
351
|
+
item_content
|
|
352
|
+
}
|
|
353
|
+
let offset = paragraph-text-offset(nest_level, level-counts, indent-fn)
|
|
354
|
+
if offset == 0pt { quoted } else { pad(left: offset, quoted) }
|
|
272
355
|
} else if kind == "continuation" {
|
|
273
356
|
// Continuation block within a multi-block list item:
|
|
274
357
|
// indent to align with preceding numbered paragraph's text, no new number.
|
|
@@ -27,11 +27,15 @@
|
|
|
27
27
|
// Format of indorsement: "standard" (same page), "informal" (no header), or "separate_page" (starts on new page)
|
|
28
28
|
format: "standard",
|
|
29
29
|
// Decision action. `none` (default) displays no action line at all.
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// Coordination pair: "concur" or "nonconcur" circles the selected option;
|
|
33
|
-
// "undecided_concur" displays the pair with neither circled.
|
|
30
|
+
// "approve" or "disapprove" underlines the selected option and strikes the
|
|
31
|
+
// other; "undecided" displays the pair with neither marked.
|
|
34
32
|
action: none,
|
|
33
|
+
// Role this indorsement plays in the coordination chain, which selects the
|
|
34
|
+
// wording of the action line: the approval authority (the last indorsement
|
|
35
|
+
// in the chain) Approves / Disapproves, every coordinating official before
|
|
36
|
+
// it Concurs / Nonconcurs. The caller owns this because only it can see
|
|
37
|
+
// whether further indorsements follow.
|
|
38
|
+
approval_authority: false,
|
|
35
39
|
content,
|
|
36
40
|
) = {
|
|
37
41
|
// Validate format parameter
|
|
@@ -160,7 +164,11 @@
|
|
|
160
164
|
|
|
161
165
|
// Show action line only when an action decision is set (not `none`)
|
|
162
166
|
if effective_action != none {
|
|
163
|
-
render-action-line(
|
|
167
|
+
render-action-line(
|
|
168
|
+
effective_action,
|
|
169
|
+
approval-authority: approval_authority,
|
|
170
|
+
trailing-blank-line: not body_empty,
|
|
171
|
+
)
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
if not body_empty {
|
|
@@ -306,37 +306,46 @@
|
|
|
306
306
|
// plays in a coordination chain: coordinating officials Concur / Nonconcur,
|
|
307
307
|
// while the final approval authority Approves / Disapproves.
|
|
308
308
|
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
309
|
+
// Which pair prints is not the endorser's to choose — it follows from the
|
|
310
|
+
// role, so the caller supplies the role via `approval-authority` and the same
|
|
311
|
+
// three `action` values (affirm, reject, or leave open) carry over both pairs.
|
|
312
|
+
//
|
|
313
|
+
// The "undecided" form renders both options plain, for printing a memo the
|
|
314
|
+
// endorser marks by hand when signing.
|
|
311
315
|
//
|
|
312
316
|
// Empty/none suppression is handled by the caller before this is invoked.
|
|
313
317
|
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
#let
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
"
|
|
322
|
-
"
|
|
318
|
+
// The option pair for each role, ordered (affirmative, negative).
|
|
319
|
+
#let APPROVAL_OPTIONS = ("Approve", "Disapprove")
|
|
320
|
+
#let COORDINATION_OPTIONS = ("Concur", "Nonconcur")
|
|
321
|
+
|
|
322
|
+
// Maps each `action` value to the index of the option it selects within
|
|
323
|
+
// whichever pair is rendered (`none` selects neither).
|
|
324
|
+
#let ACTION_SELECTIONS = (
|
|
325
|
+
"undecided": none,
|
|
326
|
+
"approve": 0,
|
|
327
|
+
"disapprove": 1,
|
|
323
328
|
)
|
|
324
329
|
|
|
325
|
-
#let render-action-line(action, trailing-blank-line: true) = {
|
|
330
|
+
#let render-action-line(action, approval-authority: false, trailing-blank-line: true) = {
|
|
326
331
|
assert(
|
|
327
|
-
action in
|
|
328
|
-
message: "action must be one of " +
|
|
332
|
+
action in ACTION_SELECTIONS,
|
|
333
|
+
message: "action must be one of " + ACTION_SELECTIONS.keys().map(k => "\"" + k + "\"").join(", "),
|
|
329
334
|
)
|
|
330
|
-
let
|
|
331
|
-
|
|
332
|
-
// one it was chosen over. When
|
|
333
|
-
//
|
|
335
|
+
let options = if approval-authority { APPROVAL_OPTIONS } else { COORDINATION_OPTIONS }
|
|
336
|
+
let selected = ACTION_SELECTIONS.at(action)
|
|
337
|
+
// Underline the selected option; strike the one it was chosen over. When
|
|
338
|
+
// neither is selected both render plain. An underline keeps the option on
|
|
339
|
+
// the line's own baseline — the box it replaces had to be nudged with
|
|
340
|
+
// `baseline` to sit straight — and reads as a mark made on the page, where a
|
|
341
|
+
// ruled rectangle in a PDF carrying real AcroForm widgets reads as one more
|
|
342
|
+
// fillable field.
|
|
334
343
|
let render-option(index) = {
|
|
335
344
|
let option = options.at(index)
|
|
336
345
|
if selected == none {
|
|
337
346
|
option
|
|
338
347
|
} else if selected == index {
|
|
339
|
-
|
|
348
|
+
underline(option)
|
|
340
349
|
} else {
|
|
341
350
|
strike(option)
|
|
342
351
|
}
|
|
@@ -18,26 +18,33 @@
|
|
|
18
18
|
// Value is a `length` set once in `frontmatter`.
|
|
19
19
|
#let LINE_STRIDE = state("LINE_STRIDE")
|
|
20
20
|
|
|
21
|
+
/// The height one wrapped line of body text occupies.
|
|
22
|
+
///
|
|
23
|
+
/// Read from `LINE_STRIDE` (cached in `frontmatter`), falling back to an inline
|
|
24
|
+
/// measurement when the cache is unset. Reads state, so callers must already be
|
|
25
|
+
/// in a `context`.
|
|
26
|
+
///
|
|
27
|
+
/// -> length
|
|
28
|
+
#let line-stride() = {
|
|
29
|
+
let stride = LINE_STRIDE.get()
|
|
30
|
+
if stride == none {
|
|
31
|
+
let one-line = measure(par(spacing: 0pt)[x]).height
|
|
32
|
+
stride = measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
|
|
33
|
+
}
|
|
34
|
+
stride
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
/// Creates vertical spacing equivalent to multiple blank lines.
|
|
22
38
|
///
|
|
23
39
|
/// Adds `count` wrapped-line strides on top of the natural inter-paragraph
|
|
24
40
|
/// gap, so a blank line occupies exactly the same vertical space as a line
|
|
25
|
-
/// produced by natural paragraph wrapping.
|
|
26
|
-
/// `LINE_STRIDE` (cached in `frontmatter`) and falls back to an inline
|
|
27
|
-
/// measurement when the cache is unset.
|
|
41
|
+
/// produced by natural paragraph wrapping.
|
|
28
42
|
///
|
|
29
43
|
/// - count (int): Number of blank lines to create
|
|
30
44
|
/// -> content
|
|
31
45
|
#let blank-lines(count) = {
|
|
32
46
|
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
|
|
38
|
-
}
|
|
39
|
-
v(stride * count)
|
|
40
|
-
}
|
|
47
|
+
context { v(line-stride() * count) }
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
/// Creates vertical spacing equivalent to one blank line.
|
|
@@ -129,17 +136,24 @@
|
|
|
129
136
|
]
|
|
130
137
|
}
|
|
131
138
|
|
|
139
|
+
/// The date pattern a memo style prints, per AFH 33-337. Exported so a caller
|
|
140
|
+
/// that pre-formats the date matches this package instead of restating it.
|
|
141
|
+
///
|
|
142
|
+
/// - memo-style (str): `"usaf"` or `"daf"`
|
|
143
|
+
/// -> str
|
|
144
|
+
#let date-pattern(memo-style: "usaf") = if memo-style == "daf" {
|
|
145
|
+
"[month repr:long] [day padding:none], [year]"
|
|
146
|
+
} else {
|
|
147
|
+
"[day padding:none] [month repr:long] [year]"
|
|
148
|
+
}
|
|
149
|
+
|
|
132
150
|
/// Formats a date for the memo heading.
|
|
133
151
|
///
|
|
134
|
-
///
|
|
135
|
-
///
|
|
136
|
-
/// - datetime:
|
|
137
|
-
/// `DD Month YYYY`, DAF style `Month DD, YYYY`.
|
|
138
|
-
/// - dict: a Quillmark `date`/`datetime` field lowers to a click-to-edit value
|
|
139
|
-
/// object `{ value: datetime, display: closure }`; `display` is a stored closure
|
|
140
|
-
/// over `.display`, so call it through parentheses — a dict has no `.display` method.
|
|
152
|
+
/// - str, content: shown as-is (a fixed placeholder, or ink the caller already
|
|
153
|
+
/// formatted through [`date-pattern`]).
|
|
154
|
+
/// - datetime: USAF style `DD Month YYYY`; DAF style `Month DD, YYYY`.
|
|
141
155
|
///
|
|
142
|
-
/// - date (str |
|
|
156
|
+
/// - date (str | content | datetime): Date to format for display
|
|
143
157
|
/// - memo-style (str): `"usaf"` or `"daf"`
|
|
144
158
|
/// -> content
|
|
145
159
|
#let display-date(date, memo-style: "usaf") = {
|
|
@@ -147,19 +161,12 @@
|
|
|
147
161
|
memo-style in ("usaf", "daf"),
|
|
148
162
|
message: "memo-style for display-date must be \"usaf\" or \"daf\"",
|
|
149
163
|
)
|
|
150
|
-
|
|
164
|
+
// A caller that pre-formatted — to keep a click-to-edit region the package
|
|
165
|
+
// cannot mint itself — passes the finished ink through untouched.
|
|
166
|
+
if type(date) in (str, content) {
|
|
151
167
|
date
|
|
152
168
|
} else {
|
|
153
|
-
|
|
154
|
-
"[month repr:long] [day padding:none], [year]"
|
|
155
|
-
} else {
|
|
156
|
-
"[day padding:none] [month repr:long] [year]"
|
|
157
|
-
}
|
|
158
|
-
if type(date) == datetime {
|
|
159
|
-
date.display(pattern)
|
|
160
|
-
} else {
|
|
161
|
-
(date.display)(pattern)
|
|
162
|
-
}
|
|
169
|
+
date.display(date-pattern(memo-style: memo-style))
|
|
163
170
|
}
|
|
164
171
|
}
|
|
165
172
|
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
#import "@local/quillmark-helper:0.1.0":
|
|
2
|
-
|
|
1
|
+
#import "@local/quillmark-helper:0.1.0": (
|
|
2
|
+
data, display, field-region, form-field, signature-field,
|
|
3
|
+
)
|
|
4
|
+
#import "@local/tonguetoquill-usaf-memo:4.0.0": (
|
|
5
|
+
backmatter, date-pattern, frontmatter, indorsement, mainmatter,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// A memo has no "no style" state, so the blank takes the package's default.
|
|
9
|
+
// Resolved once here: `frontmatter` and the date's pattern must agree.
|
|
10
|
+
#let memo_style = if data.memo_style != "" { data.memo_style } else { "usaf" }
|
|
3
11
|
|
|
4
12
|
// The letterhead arrives as one ordered array of lines. Element 0 is the title,
|
|
5
13
|
// set larger; the rest are caption lines beneath it. A single element renders
|
|
@@ -31,8 +39,28 @@
|
|
|
31
39
|
))
|
|
32
40
|
},
|
|
33
41
|
|
|
34
|
-
// Date
|
|
35
|
-
|
|
42
|
+
// Date. `data.date` is the native `datetime` and would render identically,
|
|
43
|
+
// but its ink would be born inside the package and carry no schema address.
|
|
44
|
+
// `display` places the field's *content* projection instead: the glyphs are
|
|
45
|
+
// born in the generated helper, so the memo date stays click-to-edit however
|
|
46
|
+
// deep the package formats it.
|
|
47
|
+
//
|
|
48
|
+
// A blank date means today's, and the plate stamps it rather than falling
|
|
49
|
+
// through to `frontmatter`'s own `datetime.today()`: package-born ink carries
|
|
50
|
+
// no address, so the one date a memo never types would be the one date a
|
|
51
|
+
// preview cannot click. `field-region` claims that ink for the field instead.
|
|
52
|
+
//
|
|
53
|
+
// The stamp is markup, not the bare `str` `.display()` returns: a `str` off a
|
|
54
|
+
// function call carries no source position, and ink with none is unclaimable.
|
|
55
|
+
date: {
|
|
56
|
+
let pattern = date-pattern(memo-style: memo_style)
|
|
57
|
+
let authored = display("date", pattern)
|
|
58
|
+
if authored != none {
|
|
59
|
+
authored
|
|
60
|
+
} else {
|
|
61
|
+
field-region("date", [#datetime.today().display(pattern)])
|
|
62
|
+
}
|
|
63
|
+
},
|
|
36
64
|
|
|
37
65
|
// Receiver information
|
|
38
66
|
memo_for: data.memo_for,
|
|
@@ -49,20 +77,28 @@
|
|
|
49
77
|
// Optional footer tag line
|
|
50
78
|
footer_tag_line: data.tag_line,
|
|
51
79
|
|
|
52
|
-
//
|
|
53
|
-
|
|
80
|
+
// The blank reads as no banner, which is what the package's own
|
|
81
|
+
// `classification_level: none` default means.
|
|
82
|
+
classification_level: data.classification.value,
|
|
54
83
|
|
|
55
84
|
dissemination: data.dissemination,
|
|
56
85
|
|
|
57
|
-
// CUI designation indicator block fields (DoDM 5200.48)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
86
|
+
// CUI designation indicator block fields (DoDM 5200.48). `classification`
|
|
87
|
+
// declares a `CUI` variant, so these four exist only where the discriminant
|
|
88
|
+
// reads CUI, and the branch is what makes reading them total: inside it every
|
|
89
|
+
// declared field of that world is present, outside it none is. The package's
|
|
90
|
+
// own `cui_*: none` defaults cover the worlds that omit them.
|
|
91
|
+
..if data.classification.value == "CUI" {
|
|
92
|
+
(
|
|
93
|
+
cui_controlled_by: data.classification.controlled_by,
|
|
94
|
+
cui_category: data.classification.category,
|
|
95
|
+
cui_limited_dissemination: data.classification.limited_dissemination,
|
|
96
|
+
cui_poc: data.classification.poc,
|
|
97
|
+
)
|
|
98
|
+
},
|
|
62
99
|
|
|
63
|
-
// USAF vs DAF memorandum style (date format, body indentation).
|
|
64
|
-
|
|
65
|
-
..if data.memo_style != "" { (memo_style: data.memo_style) },
|
|
100
|
+
// USAF vs DAF memorandum style (date format, body indentation).
|
|
101
|
+
memo_style: memo_style,
|
|
66
102
|
|
|
67
103
|
// Font size
|
|
68
104
|
font_size: body_font_size,
|
|
@@ -95,6 +131,26 @@
|
|
|
95
131
|
..if data.attachments.len() > 0 { (attachments: data.attachments) },
|
|
96
132
|
)
|
|
97
133
|
|
|
134
|
+
// The action line's wording follows the indorsement's place in the chain, not
|
|
135
|
+
// an endorser's choice: the last indorsement is the approval authority and
|
|
136
|
+
// reads Approve / Disapprove, every one before it is a coordinating official
|
|
137
|
+
// and reads Concur / Nonconcur. Only this loop can see whether an indorsement
|
|
138
|
+
// is the last one, so the position is resolved here and passed down. Cards of
|
|
139
|
+
// other kinds may interleave, so the last indorsement is the last card *of
|
|
140
|
+
// this kind*, not the last card. `-1` never matches a real index, which is the
|
|
141
|
+
// wanted result when there are no indorsements at all.
|
|
142
|
+
// Parenthesized so the method chain can span lines: in markup, a `#let` whose
|
|
143
|
+
// expression ends at a line break stops there, and the next line's leading
|
|
144
|
+
// `.at(…)` would be read as text.
|
|
145
|
+
#let last_indorsement_index = (
|
|
146
|
+
data
|
|
147
|
+
.at("$cards")
|
|
148
|
+
.enumerate()
|
|
149
|
+
.filter(entry => entry.at(1).at("$kind", default: none) == "indorsement")
|
|
150
|
+
.map(entry => entry.at(0))
|
|
151
|
+
.at(-1, default: -1)
|
|
152
|
+
)
|
|
153
|
+
|
|
98
154
|
// Indorsements - iterate through CARDS array and filter by CARD tag
|
|
99
155
|
#for (i, card) in data.at("$cards").enumerate() {
|
|
100
156
|
if card.at("$kind", default: none) == "indorsement" {
|
|
@@ -108,12 +164,13 @@
|
|
|
108
164
|
// it (distinct from the originating memo's date). The signing date is
|
|
109
165
|
// generally unknown at compile time, so a blank or omitted date leaves the
|
|
110
166
|
// date slot fillable rather than stamping the compile date into it.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
167
|
+
// `display` takes an address, so a per-card call yields a per-card region
|
|
168
|
+
// even though every iteration shares one `card` loop variable, and it
|
|
169
|
+
// returns `none` for a blank date — the fill-in case below.
|
|
170
|
+
let resolved_date = display(
|
|
171
|
+
card.at("$path") + "date",
|
|
172
|
+
date-pattern(memo-style: memo_style),
|
|
173
|
+
)
|
|
117
174
|
// The card's `$path` prefix composes its canonical schema addresses
|
|
118
175
|
// (`$cards.indorsement.<n>.…`, per-kind ordinal) — the absolute loop
|
|
119
176
|
// index `i` is NOT that ordinal once kinds interleave, so it stays a
|
|
@@ -144,6 +201,7 @@
|
|
|
144
201
|
))
|
|
145
202
|
},
|
|
146
203
|
..if card.action != "" { (action: card.action) },
|
|
204
|
+
approval_authority: i == last_indorsement_index,
|
|
147
205
|
body_content,
|
|
148
206
|
)
|
|
149
207
|
}
|