@airmark/quiver 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +1 -0
  2. package/package.json +4 -4
  3. package/quills/af4141/0.1.0/plate.typ +5 -5
  4. package/quills/afmc_moa/0.0.1/Quill.yaml +289 -0
  5. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
  6. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
  7. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
  8. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
  9. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
  10. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/attachment.typ +51 -0
  11. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/backmatter.typ +66 -0
  12. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/body.typ +126 -0
  13. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/config.typ +31 -0
  14. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/frontmatter.typ +74 -0
  15. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/lib.typ +41 -0
  16. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/mainmatter.typ +5 -0
  17. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/utils.typ +127 -0
  18. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/typst.toml +19 -0
  19. package/quills/afmc_moa/0.0.1/plate.typ +111 -0
  20. package/quills/daf1206/0.1.0/plate.typ +10 -10
  21. package/quills/daf4392/0.1.0/plate.typ +13 -13
  22. package/quills/usaf_memo/0.2.0/Quill.yaml +0 -2
  23. package/quills/usaf_memo/0.2.0/plate.typ +33 -28
  24. package/quills/usaf_memo/0.3.0/Quill.yaml +2 -4
  25. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +30 -25
  26. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +5 -1
  27. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +28 -12
  28. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +22 -14
  29. package/quills/usaf_memo/0.3.0/plate.typ +54 -31
@@ -308,35 +308,40 @@
308
308
  // `context { for … }` block, where it does not combine with the
309
309
  // preceding header section's block-spacing the same way as a
310
310
  // top-level blank-line() call.
311
- // AFH 33-337 §11 rule applied below: "Avoid dividing a paragraph of
312
- // less than four lines between two pages"
313
311
  if any_emitted { blank-line() }
314
312
  any_emitted = true
315
313
  if i == total_count {
314
+ // AFH 33-337 "Signature Block": "Do not place the signature element on a
315
+ // continuation page by itself." The signature block that follows has no
316
+ // keep-with-previous of its own — Typst has no such property — so the
317
+ // anchor comes from this side: a sticky last element is carried onto the
318
+ // next page along with the signature instead of breaking away from it.
319
+ //
320
+ // Bounded, because a sticky block does not *split* at a page boundary,
321
+ // it relocates whole; applied to a half-page block that turns an
322
+ // ordinary mid-paragraph break into a wholesale jump. A third of the
323
+ // text block covers what a memorandum actually ends on — a closing
324
+ // paragraph, a short table — while leaving the long block to be divided
325
+ // by the break as before, which leaves its own tail on the continuation
326
+ // page for the signature to sit under.
327
+ //
328
+ // The budget is a fixed fraction rather than the space actually left
329
+ // below this element, which is the question one would rather ask. A
330
+ // position-dependent test cannot be answered honestly here: on the first
331
+ // layout pass `here()` resolves against an empty introspector and reports
332
+ // the top of the page, so every element measures as fitting and
333
+ // relocates — and from the top of its new page the same test agrees, so
334
+ // the wrong answer is a fixed point that later passes never revisit.
335
+ //
336
+ // (The superseded rule was sticky under four lines, reading §11 — "avoid
337
+ // dividing a paragraph of less than four lines between two pages" — as
338
+ // if it governed the signature. It does not; it governs how a paragraph
339
+ // may be divided. All the four-line gate did was leave every body ending
340
+ // in a longer paragraph, or in a table, free to strand its signature.)
316
341
  let available_width = page.width - spacing.margin * 2
317
-
318
- // Use the shared measured line stride used by blank-line spacing.
319
- let line_height = {
320
- let cached = LINE_STRIDE.get()
321
- if cached != none {
322
- cached
323
- } else {
324
- let one-line = measure(par(spacing: 0pt)[x]).height
325
- measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
326
- }
327
- }
328
- // Calculate last item's height
329
- let par_height = measure(final_par, width: available_width).height
330
-
331
- let estimated_lines = calc.ceil(par_height / line_height)
332
-
333
- if estimated_lines < 4 {
334
- // Short content (< 4 lines): make sticky to keep with signature
335
- block(sticky: true)[#final_par]
336
- } else {
337
- // Longer content (≥ 4 lines): use default breaking behavior
338
- block(breakable: true)[#final_par]
339
- }
342
+ let relocation_budget = (page.height - spacing.margin * 2) / 3
343
+ let element_height = measure(final_par, width: available_width).height
344
+ block(sticky: element_height <= relocation_budget)[#final_par]
340
345
  } else {
341
346
  // Wrap every non-last emission in a plain block so the document-wide
342
347
  // `set block(above: spacing.line)` rule contributes the same 0.5em
@@ -20,6 +20,10 @@
20
20
  signature_blank_lines: 4,
21
21
  signing_field: none,
22
22
  date: none,
23
+ // Fill-in widget for an omitted `date`, anchored in the date slot of the
24
+ // indorsement header (see `date-placeholder-slot`). `none` leaves the slot
25
+ // blank.
26
+ date_field: none,
23
27
  // Format of indorsement: "standard" (same page), "informal" (no header), or "separate_page" (starts on new page)
24
28
  format: "standard",
25
29
  // Decision action. `none` (default) displays no action line at all.
@@ -79,7 +83,7 @@
79
83
  let indorsement_number = counters.indorsement.get().at(0, default: 1)
80
84
  let indorsement_label = format-indorsement-number(indorsement_number)
81
85
 
82
- let ind_date = align(right)[#if actual_date != none { display-date(actual_date, memo-style: memo-style) } else { date-placeholder-line() }]
86
+ let ind_date = align(right)[#if actual_date != none { display-date(actual_date, memo-style: memo-style) } else { date-placeholder-slot(date_field) }]
83
87
 
84
88
  // Separate-page header body: restates the original memo's identity (FROM,
85
89
  // date, subject) on its own line, since the indorsement no longer shares a
@@ -224,9 +224,6 @@
224
224
 
225
225
  #let render-signature-block(signature-lines, signature-blank-lines: 4, signing-field: none) = {
226
226
  signature-lines = ensure-array(signature-lines)
227
- // AFH 33-337: "fifth line below" = 4 blank lines between text and signature block.
228
- // breakable: false discourages orphaning the signature block onto a page by itself.
229
- blank-lines(signature-blank-lines)
230
227
  // AFH 33-337 allows two equivalent anchors: 4.5in from the left edge, or three
231
228
  // spaces right of page center. On 8.5in stock these coincide (page center =
232
229
  // 4.25in; three TNR-12pt spaces ≈ 0.25in), so we use 4.5in as the canonical
@@ -240,6 +237,13 @@
240
237
  let w = measure(text(hyphenate: false, line)).width
241
238
  if w > widest { widest = w }
242
239
  }
240
+ let stride = {
241
+ let s = LINE_STRIDE.get()
242
+ if s == none {
243
+ let one-line = measure(par(spacing: 0pt)[x]).height
244
+ measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
245
+ } else { s }
246
+ }
243
247
  // If the widest line would overflow the right margin at the standard
244
248
  // anchor, shift the block left just enough to fit. Clamp at 0 so the
245
249
  // block never crosses the left margin.
@@ -251,18 +255,30 @@
251
255
  default-pad
252
256
  }
253
257
  block(breakable: false)[
258
+ #let gap = stride * signature-blank-lines
259
+ // AFH 33-337: "fifth line below the last line of text" = four blank lines
260
+ // between the text and the signature block. Carried INSIDE the
261
+ // unbreakable block rather than emitted ahead of it, which keeps the gap
262
+ // and the block one indivisible unit. The total space above the block is
263
+ // identical either way — `block.above` still contributes the same 0.5em —
264
+ // but a gap left outside can be consumed at the foot of one page while
265
+ // the block starts at the top margin of the next, which is also what put
266
+ // the signing field off the page (see below).
267
+ #v(gap)
254
268
  #if signing-field != none {
255
- let stride = {
256
- let s = LINE_STRIDE.get()
257
- if s == none {
258
- let one-line = measure(par(spacing: 0pt)[x]).height
259
- measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
260
- } else { s }
261
- }
269
+ // The signing field covers those blank lines — where a signature is
270
+ // actually written — so it is placed over the gap. With the gap inside
271
+ // the block that is a downward offset from the block's own origin, and
272
+ // the field therefore travels with the block onto whatever page it
273
+ // lands on. The superseded form placed it at a NEGATIVE dy, reaching
274
+ // above the block into space that belongs to the previous page: when
275
+ // the block started at the top margin, the field was painted into the
276
+ // header band over the page number and the classification banner, or
277
+ // off the sheet entirely.
262
278
  place(
263
279
  dx: left-pad,
264
- dy: -(stride * signature-blank-lines),
265
- box(width: body-width - left-pad, height: stride * signature-blank-lines, signing-field),
280
+ dy: 0pt,
281
+ box(width: body-width - left-pad, height: gap, signing-field),
266
282
  )
267
283
  }
268
284
  #align(left)[
@@ -163,24 +163,32 @@
163
163
  }
164
164
  }
165
165
 
166
- /// Renders a horizontal rule sized to fit a handwritten date.
167
- ///
168
- /// Used for indorsements whose signing date is unknown at compile time: the
169
- /// endorser writes the date on the line by hand when signing. The rule sits at
170
- /// the baseline with one line of height above it so handwritten text can be
171
- /// written on the line without colliding with surrounding header text.
172
- ///
173
- /// - width (length): Length of the fill-in rule; defaults to fit a long date
174
- /// such as "15 September 2026".
166
+ /// Reserves the space a date occupies, for a date unknown at compile time.
167
+ ///
168
+ /// Used for indorsements whose signing date is not known when the memo is
169
+ /// rendered: the endorser supplies it after the fact. The reserved box is one
170
+ /// line tall and sits on the baseline, so the slot lines up with where a
171
+ /// printed date would sit and takes exactly the space one would take.
172
+ ///
173
+ /// `field` is the caller's fill-in widget — the Quillmark helper's
174
+ /// `form-field(.., type: "text")`, which lowers to a fillable AcroForm text box
175
+ /// in PDF output. It is anchored at the slot's top-left corner, the corner the
176
+ /// widget's own rectangle grows right and down from, so a widget declared at
177
+ /// this slot's dimensions covers it exactly. `none` (a caller with no widget to
178
+ /// give, e.g. the package used outside Quillmark) leaves the slot blank.
179
+ ///
180
+ /// - field (content | none): Fill-in widget to anchor in the slot
181
+ /// - width (length): Width of the slot; defaults to fit a long date such as
182
+ /// "15 September 2026".
175
183
  /// -> content
176
- #let date-placeholder-line(width: 1in) = box(
184
+ #let date-placeholder-slot(field, width: 1in) = box(
177
185
  width: width,
178
186
  height: 1em,
179
- // Keep the rule on the line's baseline so it aligns with where the printed
180
- // date would sit. The 1em box height reserves the writing space above it.
181
- // (A positive baseline shift would drop the rule a full line too low.)
187
+ // Keep the slot's bottom edge on the line's baseline, where the printed date
188
+ // would sit; the 1em height then reserves the line above it.
189
+ // (A positive baseline shift would drop the box a full line too low.)
182
190
  baseline: 0pt,
183
- stroke: (bottom: 0.5pt + black),
191
+ if field != none { place(top + left, field) },
184
192
  )
185
193
 
186
194
  /// Gets the banner color for a classification marking.
@@ -1,4 +1,4 @@
1
- #import "@local/quillmark-helper:0.1.0": data, signature-field
1
+ #import "@local/quillmark-helper:0.1.0": data, form-field, signature-field
2
2
  #import "@local/tonguetoquill-usaf-memo:4.0.0": backmatter, frontmatter, indorsement, mainmatter
3
3
 
4
4
  // The letterhead arrives as one ordered array of lines. Element 0 is the title,
@@ -8,54 +8,64 @@
8
8
  // leaves no glyphs and takes no space from the flow, and the seal stands alone.
9
9
  #let letterhead_lines = data.letterhead_title
10
10
 
11
+ // Body text size, in points. Also the height of one line of the indorsement
12
+ // header, which is what an omitted indorsement date reserves for its fill-in
13
+ // widget (`date-placeholder-slot`, whose slot is `1em` tall and 1in wide).
14
+ #let body_font_size = data.font_size * 1pt
15
+
11
16
  // Frontmatter configuration
12
17
  #show: frontmatter.with(
13
18
  // Letterhead configuration
14
19
  letterhead_title: letterhead_lines.at(0, default: ""),
15
20
  letterhead_caption: if letterhead_lines.len() > 1 { letterhead_lines.slice(1) } else { () },
16
- letterhead_seal_subtitle: data.at("letterhead_seal_subtitle", default: none),
17
- letterhead_seal: image(
18
- if data.at("letterhead_seal", default: "dow") == "dod" {
19
- "assets/dod_seal.png"
20
- } else {
21
- "assets/dow_seal.png"
22
- }
23
- ),
21
+ letterhead_seal_subtitle: data.letterhead_seal_subtitle,
22
+ // Enum blank is `""`, not a seal. Omit so the package renders none rather
23
+ // than treating the blank as DoW.
24
+ ..if data.letterhead_seal != "" {
25
+ (letterhead_seal: image(
26
+ if data.letterhead_seal == "dod" {
27
+ "assets/dod_seal.png"
28
+ } else {
29
+ "assets/dow_seal.png"
30
+ }
31
+ ))
32
+ },
24
33
 
25
34
  // Date
26
- date: data.at("date", default: none),
35
+ date: data.date,
27
36
 
28
37
  // Receiver information
29
38
  memo_for: data.memo_for,
30
39
 
31
40
  // Sender information (omitted for Memorandum for Record)
32
- ..if data.at("memo_from", default: ()).len() > 0 { (memo_from: data.memo_from) },
41
+ ..if data.memo_from.len() > 0 { (memo_from: data.memo_from) },
33
42
 
34
43
  // Subject line
35
44
  subject: data.subject,
36
45
 
37
46
  // Optional references
38
- ..if "references" in data { (references: data.references) },
47
+ ..if data.references.len() > 0 { (references: data.references) },
39
48
 
40
49
  // Optional footer tag line
41
- ..if "tag_line" in data { (footer_tag_line: data.tag_line) },
50
+ footer_tag_line: data.tag_line,
42
51
 
43
52
  // Optional classification level
44
- ..if "classification" in data { (classification_level: data.classification) },
53
+ ..if data.classification != "" { (classification_level: data.classification) },
45
54
 
46
- ..if "dissemination" in data { (dissemination: data.dissemination) },
55
+ dissemination: data.dissemination,
47
56
 
48
57
  // CUI designation indicator block fields (DoDM 5200.48)
49
- ..if "cui_controlled_by" in data { (cui_controlled_by: data.cui_controlled_by) },
50
- ..if "cui_category" in data { (cui_category: data.cui_category) },
51
- ..if "cui_limited_dissemination" in data { (cui_limited_dissemination: data.cui_limited_dissemination) },
52
- ..if "cui_poc" in data { (cui_poc: data.cui_poc) },
58
+ cui_controlled_by: data.cui_controlled_by,
59
+ cui_category: data.cui_category,
60
+ cui_limited_dissemination: data.cui_limited_dissemination,
61
+ cui_poc: data.cui_poc,
53
62
 
54
- // USAF vs DAF memorandum style (date format, body indentation)
55
- memo_style: data.at("memo_style", default: "usaf"),
63
+ // USAF vs DAF memorandum style (date format, body indentation). Blank
64
+ // falls through to the package's `"usaf"` default.
65
+ ..if data.memo_style != "" { (memo_style: data.memo_style) },
56
66
 
57
67
  // Font size
58
- font_size: data.at("font_size", default: 12) * 1pt,
68
+ font_size: body_font_size,
59
69
 
60
70
  // List recipients in vertical list
61
71
  memo_for_cols: 1,
@@ -76,18 +86,18 @@
76
86
  signing_field: signature-field("Signature", field: "signature_block"),
77
87
 
78
88
  // Optional cc
79
- ..if "cc" in data { (cc: data.cc) },
89
+ ..if data.cc.len() > 0 { (cc: data.cc) },
80
90
 
81
91
  // Optional distribution
82
- ..if "distribution" in data { (distribution: data.distribution) },
92
+ ..if data.distribution.len() > 0 { (distribution: data.distribution) },
83
93
 
84
94
  // Optional attachments
85
- ..if "attachments" in data { (attachments: data.attachments) },
95
+ ..if data.attachments.len() > 0 { (attachments: data.attachments) },
86
96
  )
87
97
 
88
98
  // Indorsements - iterate through CARDS array and filter by CARD tag
89
99
  #for (i, card) in data.at("$cards").enumerate() {
90
- if card.at("$kind") == "indorsement" {
100
+ if card.at("$kind", default: none) == "indorsement" {
91
101
  // The quillmark helper leaves an unset/whitespace-only markdown body as
92
102
  // the empty string `""`; only non-empty bodies are eval'd into content.
93
103
  // Pass truly empty content (`[]`) in the empty case so indorsement can
@@ -96,9 +106,9 @@
96
106
  let body_content = if type(body) == str { [] } else { body }
97
107
  // Per AFH 33-337 Ch. 14, an indorsement is dated when the endorser signs
98
108
  // it (distinct from the originating memo's date). The signing date is
99
- // generally unknown at compile time and filled in by hand, so a blank or
100
- // omitted date renders a fill-in line rather than stamping the compile date.
101
- let card_date = card.at("date", default: none)
109
+ // generally unknown at compile time, so a blank or omitted date leaves the
110
+ // date slot fillable rather than stamping the compile date into it.
111
+ let card_date = card.date
102
112
  let resolved_date = if card_date == none or card_date == "" {
103
113
  none
104
114
  } else {
@@ -118,9 +128,22 @@
118
128
  "Ind_" + str(i) + "_Signature",
119
129
  field: card.at("$path") + "signature_block",
120
130
  ),
121
- format: card.at("format", default: "standard"),
131
+ ..if card.format != "" { (format: card.format) },
122
132
  date: resolved_date,
123
- ..if "action" in card { (action: card.action) },
133
+ // An omitted date becomes an empty AcroForm text box the endorser types
134
+ // the signing date into, sized to the slot the package reserves for it.
135
+ // Built only when there is no date to print: a widget over a printed date
136
+ // would offer an edit that the rendered document does not carry back.
137
+ ..if resolved_date == none {
138
+ (date_field: form-field(
139
+ "Ind_" + str(i) + "_Date",
140
+ type: "text",
141
+ width: 1in,
142
+ height: body_font_size,
143
+ field: card.at("$path") + "date",
144
+ ))
145
+ },
146
+ ..if card.action != "" { (action: card.action) },
124
147
  body_content,
125
148
  )
126
149
  }