@tonguetoquill/collection 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonguetoquill/collection",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/nibsbin/tonguetoquill-collection.git"
@@ -70,6 +70,15 @@ main:
70
70
  group: Letterhead
71
71
  description: The full organization name of your unit.
72
72
 
73
+ letterhead_seal_subtitle:
74
+ title: Subtitle under letterhead seal
75
+ type: string
76
+ default: ""
77
+ ui:
78
+ group: Letterhead
79
+ compact: true
80
+ description: Optional line below the DoW seal (bold caps). Leave blank to omit.
81
+
73
82
  tag_line:
74
83
  title: Tag line at bottom of memo
75
84
  type: string
@@ -118,18 +127,19 @@ main:
118
127
  ui:
119
128
  group: Additional
120
129
  description: List attachments in the order they are mentioned in the memo. Briefly describe each; do not use 'as stated' or abbreviations.
121
-
122
- classification:
123
- title: Classification level of the memo that displays in the banner
130
+
131
+ memo_style:
132
+ title: Memorandum style
124
133
  type: string
125
- default: ""
126
- examples:
127
- - CONFIDENTIAL
134
+ enum:
135
+ - usaf
136
+ - daf
137
+ default: usaf
128
138
  ui:
129
139
  group: Additional
130
140
  compact: true
131
- description: Follow AFI 31-401 and applicable DoD guidance for classification markings. Leave blank for unclassified.
132
-
141
+ description: "USAF memorandum (default) or DAF headquarters memorandum. DAF changes date formatting and body paragraph layout per AFH 33-337."
142
+
133
143
  font_size:
134
144
  title: Font size for the memo text (int pt)
135
145
  type: number
@@ -147,9 +157,19 @@ main:
147
157
  default: ""
148
158
  ui:
149
159
  group: Additional
160
+ compact: true
150
161
  description: YYYY-MM-DD. Leave blank to use today's date.
151
-
152
-
162
+ classification:
163
+ title: Classification level of the memo that displays in the banner
164
+ type: string
165
+ default: ""
166
+ examples:
167
+ - CONFIDENTIAL
168
+ ui:
169
+ group: Additional
170
+ compact: true
171
+ description: Follow AFI 31-401 and applicable DoD guidance for classification markings. Leave blank for unclassified.
172
+
153
173
  cards:
154
174
  indorsement:
155
175
  title: Routing indorsement
@@ -13,29 +13,13 @@
13
13
 
14
14
  /// Gets the numbering format for a specific paragraph level.
15
15
  ///
16
- /// AFH 33-337 "The Text of the Official Memorandum" §2: "Number and letter each
17
- /// paragraph and subparagraph" with hierarchical numbering implied by examples.
18
- /// Standard military format follows the pattern: 1., a., (1), (a), etc.
19
- ///
20
- /// Returns the appropriate numbering format for AFH 33-337 compliant
21
- /// hierarchical paragraph numbering:
22
- /// - Level 0: "1." (1., 2., 3., etc.)
23
- /// - Level 1: "a." (a., b., c., etc.)
24
- /// - Level 2: "(1)" ((1), (2), (3), etc.)
25
- /// - Level 3: "(a)" ((a), (b), (c), etc.)
26
- /// - Level 4+: Underlined format for deeper nesting
27
- ///
28
16
  /// - level (int): Paragraph nesting level (0-based)
29
17
  /// -> str | function
30
18
  #let get-paragraph-numbering-format(level) = {
31
19
  paragraph-config.numbering-formats.at(level, default: "i.")
32
20
  }
33
21
 
34
- /// Calculates indentation width using explicit counter values.
35
- ///
36
- /// Computes the exact indentation needed for hierarchical paragraph alignment
37
- /// by measuring the cumulative width of all ancestor paragraph numbers and their
38
- /// spacing. Uses the provided counter values directly (no Typst counter reads).
22
+ /// Calculates indentation for USAF-style paragraphs from explicit counter values.
39
23
  ///
40
24
  /// - level (int): Paragraph nesting level (0-based)
41
25
  /// - level-counts (dictionary): Maps level index strings to their current counter values
@@ -49,49 +33,52 @@
49
33
  let ancestor-value = level-counts.at(str(ancestor-level), default: 1)
50
34
  let ancestor-format = get-paragraph-numbering-format(ancestor-level)
51
35
  let ancestor-number = numbering(ancestor-format, ancestor-value)
52
- let width = measure([#ancestor-number#" "]).width
53
- total-indent += width
36
+ total-indent += measure([#ancestor-number#" "]).width
54
37
  }
55
38
  total-indent
56
39
  }
57
40
 
58
- /// Formats a numbered paragraph with proper indentation.
41
+ /// Calculates fixed indentation for DAF paragraph levels.
59
42
  ///
60
- /// Generates a properly formatted paragraph with AFH 33-337 compliant numbering
61
- /// and indentation. Uses explicit level and counter values to avoid nested-context
62
- /// state propagation issues.
43
+ /// First nested level starts at `nested-first-level-indent` (1in); deeper levels
44
+ /// add `nested-step` (0.5in) per additional depth.
63
45
  ///
64
- /// - body (content): Paragraph content to format
65
46
  /// - level (int): Paragraph nesting level (0-based)
66
- /// - level-counts (dictionary): Current counter values per level
67
- /// -> content
68
- #let format-numbered-par(body, level, level-counts) = {
69
- let current-value = level-counts.at(str(level), default: 1)
70
- let format = get-paragraph-numbering-format(level)
71
- let number-text = numbering(format, current-value)
72
- let indent-width = calculate-indent-from-counts(level, level-counts)
73
- [#h(indent-width)#number-text#" "#body]
47
+ /// -> length
48
+ #let calculate-daf-indent(level) = {
49
+ if level <= 0 {
50
+ return 0pt
51
+ }
52
+ daf-paragraph.nested-first-level-indent + (level - 1) * daf-paragraph.nested-step
74
53
  }
75
54
 
76
- /// Formats a continuation paragraph within a multi-block list item.
77
- ///
78
- /// Renders a paragraph that belongs to the same list item as the preceding
79
- /// numbered paragraph. The text is indented to align with the first character
80
- /// of the preceding numbered paragraph's text (past the number and spacing),
81
- /// but no new number is generated.
55
+ /// Resets counter entries from `start` upward to 1 in the level-counts dictionary.
56
+ #let reset-levels-from(level-counts, start, max-levels) = {
57
+ for child in range(start, max-levels) {
58
+ level-counts.insert(str(child), 1)
59
+ }
60
+ level-counts
61
+ }
62
+
63
+ /// Formats a paragraph (or continuation) with a given indent strategy.
82
64
  ///
83
- /// - body (content): Continuation paragraph content to format
84
- /// - level (int): Paragraph nesting level (0-based)
65
+ /// - body (content): Paragraph content
66
+ /// - level (int): Nesting level (0-based)
85
67
  /// - level-counts (dictionary): Current counter values per level
68
+ /// - indent-fn (function): `(level, level-counts) -> length`
69
+ /// - continuation (bool): If true, adds number-label width to alignment
86
70
  /// -> content
87
- #let format-continuation-par(body, level, level-counts) = {
88
- let indent-width = calculate-indent-from-counts(level, level-counts)
89
- // Add the width of the current level's number + spacing to align with text
90
- let current-value = level-counts.at(str(level), default: 1)
91
- let format = get-paragraph-numbering-format(level)
92
- let number-text = numbering(format, current-value)
93
- let number-width = measure([#number-text#" "]).width
94
- [#h(indent-width + number-width)#body]
71
+ #let format-par(body, level, level-counts, indent-fn, continuation: false) = {
72
+ let indent-width = indent-fn(level, level-counts)
73
+ if continuation {
74
+ let current-value = level-counts.at(str(level), default: 1)
75
+ let number-text = numbering(get-paragraph-numbering-format(level), current-value)
76
+ [#h(indent-width + measure([#number-text#" "]).width)#body]
77
+ } else {
78
+ let current-value = level-counts.at(str(level), default: 1)
79
+ let number-text = numbering(get-paragraph-numbering-format(level), current-value)
80
+ [#h(indent-width)#number-text#" "#body]
81
+ }
95
82
  }
96
83
 
97
84
  // =============================================================================
@@ -103,7 +90,7 @@
103
90
  // - "A single paragraph is not numbered" (§2)
104
91
  // - First paragraph flush left, never indented
105
92
  // - Indent sub-paragraphs to align with first character of parent paragraph text
106
- #let render-body(content, auto-numbering: true) = {
93
+ #let render-body(content, auto-numbering: true, memo-style: "usaf") = {
107
94
  let PAR_BUFFER = state("PAR_BUFFER")
108
95
  PAR_BUFFER.update(())
109
96
  let NEST_DOWN = counter("NEST_DOWN")
@@ -254,6 +241,11 @@
254
241
 
255
242
  // Format based on element kind
256
243
  let nest_level = item.nest_level
244
+ let indent-fn = if memo-style == "daf" {
245
+ (level, _counts) => calculate-daf-indent(level)
246
+ } else {
247
+ (level, counts) => calculate-indent-from-counts(level, counts)
248
+ }
257
249
  let final_par = {
258
250
  if kind == "table" {
259
251
  render-memo-table(item_content)
@@ -261,22 +253,37 @@
261
253
  // Continuation block within a multi-block list item:
262
254
  // indent to align with preceding numbered paragraph's text, no new number.
263
255
  // level-counts still holds the value of the preceding numbered paragraph.
264
- if auto-numbering {
265
- format-continuation-par(item_content, nest_level, level-counts)
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)
266
264
  } else if nest_level > 0 {
267
- format-continuation-par(item_content, nest_level - 1, level-counts)
265
+ format-par(item_content, nest_level - 1, level-counts, indent-fn, continuation: true)
268
266
  } else {
269
267
  item_content
270
268
  }
269
+ } else if memo-style == "daf" {
270
+ if nest_level > 0 {
271
+ let par = format-par(item_content, nest_level, level-counts, indent-fn)
272
+ level-counts.insert(str(nest_level), level-counts.at(str(nest_level), default: 1) + 1)
273
+ level-counts = reset-levels-from(level-counts, nest_level + 1, max-levels)
274
+ par
275
+ } else {
276
+ // DAF top-level paragraphs are unnumbered and first-line indented.
277
+ // Reset nested counters so each new top-level paragraph restarts children.
278
+ level-counts = reset-levels-from(level-counts, 0, max-levels)
279
+ [#h(daf-paragraph.top-first-line-indent)#item_content]
280
+ }
271
281
  } else if auto-numbering {
272
282
  if par_count > 1 {
273
283
  // Apply paragraph numbering per AFH 33-337 §2
274
- let par = format-numbered-par(item_content, nest_level, level-counts)
275
- // Advance counter for this level and reset child levels
284
+ let par = format-par(item_content, nest_level, level-counts, indent-fn)
276
285
  level-counts.insert(str(nest_level), level-counts.at(str(nest_level)) + 1)
277
- for child in range(nest_level + 1, max-levels) {
278
- level-counts.insert(str(child), 1)
279
- }
286
+ level-counts = reset-levels-from(level-counts, nest_level + 1, max-levels)
280
287
  par
281
288
  } else {
282
289
  // AFH 33-337 §2: "A single paragraph is not numbered"
@@ -286,18 +293,14 @@
286
293
  // Unnumbered mode: only explicitly nested items (enum/list) get numbered
287
294
  if nest_level > 0 {
288
295
  let effective_level = nest_level - 1
289
- let par = format-numbered-par(item_content, effective_level, level-counts)
296
+ let par = format-par(item_content, effective_level, level-counts, indent-fn)
290
297
  level-counts.insert(str(effective_level), level-counts.at(str(effective_level)) + 1)
291
- for child in range(effective_level + 1, max-levels) {
292
- level-counts.insert(str(child), 1)
293
- }
298
+ level-counts = reset-levels-from(level-counts, effective_level + 1, max-levels)
294
299
  par
295
300
  } else {
296
- // Base-level paragraphs are flush left with no numbering
297
- // Reset all child level counters so subsequent list items restart at 1
298
- for child in range(max-levels) {
299
- level-counts.insert(str(child), 1)
300
- }
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)
301
304
  item_content
302
305
  }
303
306
  }
@@ -21,8 +21,8 @@
21
21
  // =============================================================================
22
22
  // AFH 33-337 §5: "Use 12 point Times New Roman font for text"
23
23
 
24
- #let DEFAULT_LETTERHEAD_FONTS = ("Copperplate CC",)
25
- #let DEFAULT_BODY_FONTS = ("times new roman", "NimbusRomNo9L") // AFH 33-337 §5: Times New Roman required
24
+ #let DEFAULT_LETTERHEAD_FONTS = ("NimbusRomNo9L", "times new roman")
25
+ #let DEFAULT_BODY_FONTS = ("NimbusRomNo9L", "times new roman") // AFH 33-337 §5: Times New Roman required
26
26
  #let LETTERHEAD_COLOR = rgb("#204093") // Faded USAF blue for letterhead
27
27
 
28
28
  // =============================================================================
@@ -39,6 +39,14 @@
39
39
  numbering-formats: ("1.", "a.", "(1)", "(a)", n => underline(str(n)), n => underline(str(n))),
40
40
  )
41
41
 
42
+ // DAF (Headquarters) memo body: first-line indent for unnumbered paragraphs; nested
43
+ // items start at 1in, then +0.5in per additional nesting depth.
44
+ #let daf-paragraph = (
45
+ top-first-line-indent: 0.5in,
46
+ nested-first-level-indent: 1in,
47
+ nested-step: 0.5in,
48
+ )
49
+
42
50
  // =============================================================================
43
51
  // COUNTERS
44
52
  // =============================================================================
@@ -58,7 +66,6 @@
58
66
 
59
67
  #let CLASSIFICATION_COLORS = (
60
68
  "UNCLASSIFIED": rgb(0, 122, 51), // Forest green (#007A33)
61
- "CONFIDENTIAL": rgb(0, 51, 160), // Deep blue (#0033A0)
62
69
  "SECRET": rgb(200, 16, 46), // Crimson red (#C8102E)
63
70
  "TOP SECRET": rgb(255, 103, 31), // Burnt orange (#FF671F)
64
71
  )
@@ -18,6 +18,7 @@
18
18
  letterhead_title: "DEPARTMENT OF THE AIR FORCE",
19
19
  letterhead_caption: "[YOUR SQUADRON/UNIT NAME]",
20
20
  letterhead_seal: none,
21
+ letterhead_seal_subtitle: none, // optional line under seal (9pt bold caps); ignored if no seal
21
22
  letterhead_font: DEFAULT_LETTERHEAD_FONTS,
22
23
  body_font: DEFAULT_BODY_FONTS,
23
24
  font_size: 12pt,
@@ -25,11 +26,16 @@
25
26
  classification_level: none,
26
27
  footer_tag_line: none,
27
28
  auto_numbering: true,
29
+ memo_style: "usaf",
28
30
  it,
29
31
  ) = {
30
32
  assert(subject != none, message: "subject is required")
31
33
  assert(memo_for != none, message: "memo_for is required")
32
34
  assert(memo_from != none, message: "memo_from is required")
35
+ assert(
36
+ memo_style in ("usaf", "daf"),
37
+ message: "memo_style must be \"usaf\" or \"daf\"",
38
+ )
33
39
 
34
40
  let actual_date = if date == none { datetime.today() } else { date }
35
41
  let classification_color = get-classification-level-color(classification_level)
@@ -89,13 +95,19 @@
89
95
  },
90
96
  )
91
97
 
92
- render-letterhead(letterhead_title, letterhead_caption, letterhead_seal, letterhead_font)
98
+ render-letterhead(
99
+ letterhead_title,
100
+ letterhead_caption,
101
+ letterhead_font,
102
+ letterhead-seal: letterhead_seal,
103
+ letterhead-seal-subtitle: letterhead_seal_subtitle,
104
+ )
93
105
 
94
106
  // AFH 33-337 "Date": "Place the date 1 inch from the right edge, 1.75 inches from the top"
95
107
  // Since we have a 1-inch top margin, we need (1.75in - margin) vertical space
96
108
  v(1.75in - spacing.margin)
97
109
 
98
- render-date-section(actual_date)
110
+ render-date-section(actual_date, memo-style: memo_style)
99
111
  render-for-section(memo_for, memo_for_cols)
100
112
  render-from-section(memo_from)
101
113
  render-subject-section(subject)
@@ -108,6 +120,7 @@
108
120
  body_font: body_font,
109
121
  font_size: font_size,
110
122
  auto_numbering: auto_numbering,
123
+ memo_style: memo_style,
111
124
  ))
112
125
 
113
126
  it
@@ -51,6 +51,7 @@
51
51
 
52
52
  context {
53
53
  let config = query(metadata).last().value
54
+ let memo-style = config.at("memo_style", default: "usaf")
54
55
  let original_subject = config.subject
55
56
  let original_date = config.original_date
56
57
  let original_from = config.original_from
@@ -61,12 +62,12 @@
61
62
 
62
63
  if format == "separate_page" {
63
64
  pagebreak()
64
- [#indorsement_label to #original_from, #display-date(original_date), #original_subject]
65
+ [#indorsement_label to #original_from, #display-date(original_date, memo-style: memo-style), #original_subject]
65
66
 
66
67
  blank-line()
67
68
  grid(
68
69
  columns: (auto, 1fr),
69
- ind_from, align(right)[#display-date(actual_date)],
70
+ ind_from, align(right)[#display-date(actual_date, memo-style: memo-style)],
70
71
  )
71
72
 
72
73
  blank-line()
@@ -78,7 +79,7 @@
78
79
  blank-line()
79
80
  grid(
80
81
  columns: (auto, 1fr),
81
- [#indorsement_label, #ind_from], align(right)[#display-date(actual_date)],
82
+ [#indorsement_label, #ind_from], align(right)[#display-date(actual_date, memo-style: memo-style)],
82
83
  )
83
84
 
84
85
  blank-line()
@@ -96,23 +97,15 @@
96
97
  render-action-line(action)
97
98
  }
98
99
 
99
- render-body(content)
100
+ context {
101
+ let memo-style = {
102
+ let items = query(metadata)
103
+ if items.len() > 0 { items.last().value.at("memo_style", default: "usaf") } else { "usaf" }
104
+ }
105
+ render-body(content, memo-style: memo-style)
106
+ }
100
107
 
101
108
  render-signature-block(signature_block, signature-blank-lines: signature_blank_lines)
102
109
 
103
- if not falsey(attachments) {
104
- calculate-backmatter-spacing(true)
105
- let attachment-count = attachments.len()
106
- let section-label = if attachment-count == 1 { "Attachment:" } else { str(attachment-count) + " Attachments:" }
107
- let continuation-label = (
108
- (if attachment-count == 1 { "Attachment" } else { str(attachment-count) + " Attachments" })
109
- + " (listed on next page):"
110
- )
111
- render-backmatter-section(attachments, section-label, numbering-style: "1.", continuation-label: continuation-label)
112
- }
113
-
114
- if not falsey(cc) {
115
- calculate-backmatter-spacing(falsey(attachments))
116
- render-backmatter-section(cc, "cc:")
117
- }
110
+ render-backmatter-sections(attachments: attachments, cc: cc)
118
111
  }
@@ -22,18 +22,20 @@
22
22
  //
23
23
  // Basic usage:
24
24
  //
25
- // #import "@preview/tonguetoquill-usaf-memo:2.0.0": frontmatter, mainmatter, backmatter, indorsement
25
+ // #import "@preview/tonguetoquill-usaf-memo:3.0.0": frontmatter, mainmatter, backmatter, indorsement
26
26
  //
27
27
  // #show: frontmatter.with(
28
28
  // subject: "Your Subject Here",
29
29
  // memo_for: ("OFFICE/SYMBOL",),
30
30
  // memo_from: ("YOUR/SYMBOL",),
31
+ // memo_style: "usaf", // "usaf" (default) or "daf"
31
32
  // )
32
33
  //
33
34
  // #show: mainmatter
34
35
  //
35
36
  // Your memo body content here.
36
- // (Paragraphs are automatically numbered per AFH 33-337)
37
+ // (USAF style auto-numbers per AFH 33-337; DAF style uses unnumbered
38
+ // top-level paragraphs with fixed 0.5in first-line indentation)
37
39
  //
38
40
  // #backmatter(
39
41
  // signature_block: ("NAME, Rank, USAF", "Title"),
@@ -3,7 +3,6 @@
3
3
  // This module implements the mainmatter (body text) of a USAF memorandum per
4
4
  // AFH 33-337 Chapter 14 "The Text of the Official Memorandum" (§1-12).
5
5
 
6
- #import "primitives.typ": *
7
6
  #import "body.typ": *
8
7
 
9
8
  /// Mainmatter show rule for USAF memorandum body content.
@@ -19,7 +18,7 @@
19
18
  /// of the memorandum. Automatically detects single vs. multiple paragraphs
20
19
  /// to comply with AFH 33-337 numbering requirements.
21
20
  ///
22
- /// When auto_numbering is false (set in frontmatter), base-level paragraphs
21
+ /// When `auto_numbering` is false (set in frontmatter), base-level paragraphs
23
22
  /// render flush left without numbering. Only explicitly numbered or bulleted
24
23
  /// items enter the numbering hierarchy.
25
24
  ///
@@ -28,5 +27,6 @@
28
27
  #let mainmatter(it) = context {
29
28
  let config = query(metadata).last().value
30
29
  let auto-numbering = config.at("auto_numbering", default: true)
31
- render-body(it, auto-numbering: auto-numbering)
30
+ let memo-style = config.at("memo_style", default: "usaf")
31
+ render-body(it, auto-numbering: auto-numbering, memo-style: memo-style)
32
32
  }
@@ -14,9 +14,18 @@
14
14
  // Letterhead placement is not explicitly specified in AFH 33-337, but follows
15
15
  // standard USAF memo formatting conventions
16
16
 
17
- #let render-letterhead(title, caption, letterhead-seal, font) = {
17
+ #let render-letterhead(
18
+ title,
19
+ caption,
20
+ font,
21
+ letterhead-seal: none,
22
+ letterhead-seal-subtitle: none,
23
+ ) = {
18
24
  font = ensure-array(font)
25
+ title = ensure-string(title)
19
26
  caption = ensure-string(caption)
27
+ title = upper(title)
28
+ caption = upper(caption)
20
29
 
21
30
  place(
22
31
  dy: 0.625in - spacing.margin,
@@ -28,7 +37,7 @@
28
37
  #place(
29
38
  center + top,
30
39
  align(center)[
31
- #set text(12pt, font: font, fill: LETTERHEAD_COLOR)
40
+ #set text(12pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")
32
41
  #title\
33
42
  #text(10.5pt)[#caption]
34
43
  ],
@@ -38,13 +47,27 @@
38
47
  )
39
48
 
40
49
  if letterhead-seal != none {
50
+ let seal-body = if falsey(letterhead-seal-subtitle) {
51
+ block[
52
+ #fit-box(width: 2in, height: 1in)[#letterhead-seal]
53
+ ]
54
+ } else {
55
+ block(width: 2in)[
56
+ #align(left)[
57
+ #stack(spacing: 0.15em)[
58
+ #fit-box(width: 2in, height: 1in)[#letterhead-seal]
59
+ #text(9pt, font: font, fill: LETTERHEAD_COLOR, weight: "bold")[
60
+ #upper(ensure-string(letterhead-seal-subtitle))
61
+ ]
62
+ ]
63
+ ]
64
+ ]
65
+ }
41
66
  place(
42
67
  left + top,
43
68
  dx: -0.5in,
44
69
  dy: -.5in,
45
- block[
46
- #fit-box(width: 2in, height: 1in)[#letterhead-seal]
47
- ],
70
+ seal-body,
48
71
  )
49
72
  }
50
73
  }
@@ -59,8 +82,8 @@
59
82
  // - SUBJECT: Second line below FROM
60
83
 
61
84
  // AFH 33-337 "Date": "Place the date 1 inch from the right edge, 1.75 inches from the top"
62
- #let render-date-section(date) = {
63
- align(right)[#display-date(date)]
85
+ #let render-date-section(date, memo-style: "usaf") = {
86
+ align(right)[#display-date(date, memo-style: memo-style)]
64
87
  }
65
88
 
66
89
  // AFH 33-337 "MEMORANDUM FOR": "Place 'MEMORANDUM FOR' on the second line below the date"
@@ -107,7 +130,7 @@
107
130
  blank-line()
108
131
  grid(
109
132
  columns: (auto, auto, 1fr),
110
- "References:", " ", enum(..references, numbering: "(a)"),
133
+ "References:", " ", enum(..references, numbering: "(a) ", body-indent: 0pt),
111
134
  )
112
135
  }
113
136
  }
@@ -234,10 +257,12 @@
234
257
  context {
235
258
  let available-space = page.height - here().position().y - 1in
236
259
  if measure(formatted-content).height > available-space {
260
+ // Attachments pass continuation-label ("… (listed on next page):" per AFH 33-337).
261
+ // cc: and DISTRIBUTION: use a neutral default — "listed" applies to attachment lists only.
237
262
  let continuation-text = if continuation-label != none {
238
263
  text()[#continuation-label]
239
264
  } else {
240
- text()[#section-label + " (listed on next page):"]
265
+ text()[#(section-label + " (continued on next page)")]
241
266
  }
242
267
  continuation-text
243
268
  pagebreak()
@@ -79,86 +79,51 @@
79
79
  ]
80
80
  }
81
81
 
82
- /// Checks if a string is in ISO date format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS).
82
+ /// Formats a date for the memo heading.
83
83
  ///
84
- /// Performs a simple pattern check for ISO 8601 date strings by verifying:
85
- /// - String is at least 10 characters long
86
- /// - Characters at positions 4 and 7 are dashes
87
- ///
88
- /// - date-str (str): String to check for ISO date pattern
89
- /// -> bool
90
- #let is-iso-date-string(date-str) = {
91
- if date-str.len() == 10 {
92
- let char4 = date-str.at(4)
93
- let char7 = date-str.at(7)
94
- return char4 == "-" and char7 == "-"
95
- } else if date-str.len() > 10 {
96
- let char4 = date-str.at(4)
97
- let char7 = date-str.at(7)
98
- let char10 = date-str.at(10)
99
- return char4 == "-" and char7 == "-" and char10 == "T"
100
- }
101
- return false
102
- }
103
-
104
- /// Extracts the date portion (YYYY-MM-DD) from an ISO date string.
105
- ///
106
- /// Returns the first 10 characters which contain the date portion of an
107
- /// ISO 8601 date string, removing any time component if present.
108
- ///
109
- /// - date-str (str): ISO date string to extract from
110
- /// -> str
111
- #let extract-iso-date(date-str) = {
112
- date-str.slice(0, 10)
113
- }
114
-
115
- /// Formats a date in standard military format or ISO format depending on input type.
116
- ///
117
- /// AFH 33-337 "Date": "Use the 'Day Month Year' or 'DD Mmm YY' format for documents
118
- /// addressed to a military organization. For civilian addressees, use the 'Month Day, Year' format."
119
- /// Examples: "15 October 2014" or "15 Oct 14" for military, "October 15, 2014" for civilian
120
- ///
121
- /// Intelligently handles different date input formats:
122
- /// - ISO string (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS): Parsed via TOML and displayed in military format
123
- /// - Non-ISO string: Displayed as-is
124
- /// - datetime object: Displayed in military format ("1 January 2024")
84
+ /// - String: shown as-is (use for fixed text like placeholders).
85
+ /// - datetime: USAF style `DD Month YYYY`; DAF style `Month DD, YYYY`.
125
86
  ///
126
87
  /// - date (str|datetime): Date to format for display
127
- /// -> str
128
- #let display-date(date) = {
88
+ /// - memo-style (str): `"usaf"` or `"daf"`
89
+ /// -> content
90
+ #let display-date(date, memo-style: "usaf") = {
91
+ assert(
92
+ memo-style in ("usaf", "daf"),
93
+ message: "memo-style for display-date must be \"usaf\" or \"daf\"",
94
+ )
129
95
  if type(date) == str {
130
- if is-iso-date-string(date) {
131
- // Parse ISO date string using TOML to get datetime object
132
- let iso-date = extract-iso-date(date)
133
- let toml-str = "date = " + iso-date
134
- let parsed = toml(bytes(toml-str))
135
- parsed.date.display("[day padding:none] [month repr:long] [year]")
96
+ date
97
+ } else {
98
+ let pattern = if memo-style == "daf" {
99
+ "[month repr:long] [day padding:none], [year]"
136
100
  } else {
137
- date
101
+ "[day padding:none] [month repr:long] [year]"
138
102
  }
139
- } else {
140
- date.display("[day padding:none] [month repr:long] [year]")
103
+ date.display(pattern)
141
104
  }
142
105
  }
143
106
 
144
- /// Gets the color associated with a classification level.
107
+ /// Gets the banner color for a classification marking.
108
+ ///
109
+ /// Matches when `level` (trimmed) starts with a known prefix: TOP SECRET, SECRET, or UNCLASSIFIED.
110
+ /// Otherwise returns black.
145
111
  ///
146
- /// - level (str): Classification level string
112
+ /// - level (str): Marking string shown in header/footer
147
113
  /// -> color
148
114
  #let get-classification-level-color(level) = {
149
- if level == none {
150
- return rgb(0, 0, 0) // Default to black if no classification
115
+ if level == none or type(level) != str {
116
+ return rgb(0, 0, 0)
151
117
  }
152
- // Order matters - check most specific first
153
- let level-order = ("TOP SECRET", "SECRET", "CONFIDENTIAL", "UNCLASSIFIED")
154
-
118
+ let s = level.trim()
119
+ // "TOP SECRET" before "SECRET" so the full phrase matches first.
120
+ let level-order = ("TOP SECRET", "SECRET", "UNCLASSIFIED")
155
121
  for base-level in level-order {
156
- if base-level in level {
122
+ if s.starts-with(base-level) {
157
123
  return CLASSIFICATION_COLORS.at(base-level)
158
124
  }
159
125
  }
160
-
161
- rgb(0, 0, 0) // Default
126
+ rgb(0, 0, 0)
162
127
  }
163
128
 
164
129
  // =============================================================================
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tonguetoquill-usaf-memo"
3
- version = "2.0.0"
3
+ version = "3.0.0"
4
4
  compiler = "0.14.0"
5
5
  entrypoint = "src/lib.typ"
6
6
  repository = "https://github.com/nibsbin/tonguetoquill-usaf-memo"
@@ -1,5 +1,5 @@
1
1
  #import "@local/quillmark-helper:0.1.0": data
2
- #import "@local/tonguetoquill-usaf-memo:2.0.0": backmatter, frontmatter, indorsement, mainmatter
2
+ #import "@local/tonguetoquill-usaf-memo:3.0.0": backmatter, frontmatter, indorsement, mainmatter
3
3
 
4
4
  // Frontmatter configuration
5
5
  #show: frontmatter.with(
@@ -7,6 +7,7 @@
7
7
  letterhead_title: data.letterhead_title,
8
8
  letterhead_caption: data.letterhead_caption,
9
9
  letterhead_seal: image("assets/dow_seal.png"),
10
+ letterhead_seal_subtitle: data.at("letterhead_seal_subtitle", default: none),
10
11
 
11
12
  // Date
12
13
  date: data.at("date", default: none),
@@ -29,6 +30,9 @@
29
30
  // Optional classification level
30
31
  ..if "classification" in data { (classification_level: data.classification) },
31
32
 
33
+ // USAF vs DAF memorandum style (date format, body indentation)
34
+ memo_style: data.at("memo_style", default: "usaf"),
35
+
32
36
  // Font size
33
37
  ..if "font_size" in data { (font_size: float(data.font_size) * 1pt) },
34
38
 
package/templates/loc.md CHANGED
@@ -48,7 +48,6 @@ CARD: indorsement
48
48
  from: SUBJECT RANK FIRST M. LAST
49
49
  for: ISSUER ORG/SYMBOL
50
50
  signature_block: SUBJECT FIRST M. LAST, RANK, USAF
51
- date: "Date: _____________"
52
51
  ---
53
52
 
54
53
  <!-- EDIT: Second Indorsement: Subject's response - choose one option -->
@@ -59,7 +58,6 @@ CARD: indorsement
59
58
  from: ISSUER RANK FIRST M. LAST
60
59
  for: SUBJECT RANK FIRST M. LAST
61
60
  signature_block: ISSUER FIRST M. LAST, RANK, USAF
62
- date: "Date: _____________"
63
61
  ---
64
62
 
65
63
  <!-- EDIT: Third Indorsement: Issuer's final decision - choose appropriate options -->
@@ -70,7 +68,6 @@ CARD: indorsement
70
68
  from: SUBJECT RANK FIRST M. LAST
71
69
  for: ISSUER ORG/SYMBOL
72
70
  signature_block: SUBJECT FIRST M. LAST, RANK, USAF
73
- date: "Date: _____________"
74
71
  ---
75
72
 
76
73
  <!-- EDIT: Fourth Indorsement: Subject acknowledges final decision -->