@tonguetoquill/collection 0.1.4 → 0.1.6

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.1.4",
3
+ "version": "0.1.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/nibsbin/tonguetoquill-collection.git"
@@ -22,7 +22,7 @@
22
22
  "type": "module",
23
23
  "scripts": {
24
24
  "update-subtrees": "node scripts/update-subtrees.mjs",
25
- "test": "echo \"No test specified\"",
25
+ "test": "node scripts/test.mjs",
26
26
  "build": "echo \"No build specified\"",
27
27
  "preversion": "npm test",
28
28
  "postversion": "git push --follow-tags",
@@ -35,5 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@quillmark/registry": "^0.5.3"
38
+ },
39
+ "devDependencies": {
40
+ "@quillmark/wasm": "^0.39.0"
38
41
  }
39
42
  }
@@ -193,18 +193,11 @@ cards:
193
193
  title: Action Decision
194
194
  type: string
195
195
  enum:
196
- - approved
197
- - disapproved
196
+ - approve
197
+ - disapprove
198
198
  ui:
199
199
  group: Additional
200
- description: "Action taken by the endorser ('approved' or 'disapproved')."
201
- show_action:
202
- title: Show Action Line
203
- type: boolean
204
- default: false
205
- ui:
206
- group: Additional
207
- description: "Display the APPROVED / DISAPPROVED action line."
200
+ description: "Action taken by the endorser. When set, the APPROVE / DISAPPROVE line is displayed with the selected option highlighted."
208
201
  attachments:
209
202
  title: Attachments for this endorsement
210
203
  type: array
@@ -47,7 +47,7 @@ CARD: indorsement
47
47
  for: ORG/SYMBOL
48
48
  format: standard
49
49
  from: ORG/SYMBOL
50
- action: approved
50
+ action: approve
51
51
  show_action: true
52
52
  signature_block:
53
53
  - FIRST M. LAST, Rank, USAF
@@ -31,115 +31,67 @@
31
31
  paragraph-config.numbering-formats.at(level, default: "i.")
32
32
  }
33
33
 
34
- /// Generates paragraph number for a given level with proper formatting.
35
- ///
36
- /// Creates properly formatted paragraph numbers for the hierarchical numbering
37
- /// system using Typst's native counter display capabilities.
38
- ///
39
- /// - level (int): Paragraph nesting level (0-based)
40
- /// - counter-value (none | int): Optional explicit counter value to use (for measuring widths)
41
- /// - increment (bool): Whether to increment the counter after display
42
- /// -> content
43
- #let generate-paragraph-number(level, counter-value: none) = {
44
- let paragraph-counter = counter(paragraph-config.counter-prefix + str(level))
45
- let numbering-format = get-paragraph-numbering-format(level)
46
-
47
- if counter-value != none {
48
- // For measuring widths: create temporary counter at specific value
49
- assert(counter-value >= 0, message: "Counter value of `" + str(counter-value) + "` cannot be less than 0")
50
- let temp-counter = counter("temp-counter")
51
- temp-counter.update(counter-value)
52
- temp-counter.display(numbering-format)
53
- } else {
54
- // Standard case: display and increment
55
- let result = paragraph-counter.display(numbering-format)
56
- paragraph-counter.step()
57
- result
58
- }
59
- }
60
-
61
- /// Calculates proper indentation width for a paragraph level.
62
- ///
63
- /// AFH 33-337 "The Text of the Official Memorandum" §4-5:
64
- /// - "The first paragraph is never indented; it is numbered and flush left"
65
- /// - "Indent the first line of sub-paragraphs to align the number or letter with
66
- /// the first character of its parent level paragraph"
34
+ /// Calculates indentation width using explicit counter values.
67
35
  ///
68
36
  /// Computes the exact indentation needed for hierarchical paragraph alignment
69
37
  /// by measuring the cumulative width of all ancestor paragraph numbers and their
70
- /// spacing. Uses direct iteration instead of recursion for better performance.
71
- ///
72
- /// Per AFH 33-337: Sub-paragraph text aligns with first character of parent text,
73
- /// which means indentation = sum of all ancestor number widths + spacing.
38
+ /// spacing. Uses the provided counter values directly (no Typst counter reads).
74
39
  ///
75
40
  /// - level (int): Paragraph nesting level (0-based)
41
+ /// - level-counts (dictionary): Maps level index strings to their current counter values
76
42
  /// -> length
77
- #let calculate-paragraph-indent(level) = {
78
- assert(level >= 0)
43
+ #let calculate-indent-from-counts(level, level-counts) = {
79
44
  if level == 0 {
80
45
  return 0pt
81
46
  }
82
-
83
- // Accumulate widths of all ancestor numbers iteratively
84
47
  let total-indent = 0pt
85
48
  for ancestor-level in range(level) {
86
- let ancestor-counter-value = counter(paragraph-config.counter-prefix + str(ancestor-level)).get().at(0)
87
- let ancestor-number = generate-paragraph-number(ancestor-level, counter-value: ancestor-counter-value)
88
- // Measure number + spacing buffer
49
+ let ancestor-value = level-counts.at(str(ancestor-level), default: 1)
50
+ let ancestor-format = get-paragraph-numbering-format(ancestor-level)
51
+ let ancestor-number = numbering(ancestor-format, ancestor-value)
89
52
  let width = measure([#ancestor-number#" "]).width
90
53
  total-indent += width
91
54
  }
92
-
93
55
  total-indent
94
56
  }
95
57
 
96
- /// Global state for tracking current paragraph level.
97
- /// Used internally by the paragraph numbering system to maintain proper nesting.
98
- /// -> state
99
- #let PAR_LEVEL_STATE = state("PAR_LEVEL", 0)
100
-
101
-
102
- /// Sets the current paragraph level state.
58
+ /// Formats a numbered paragraph with proper indentation.
103
59
  ///
104
- /// Internal function used by the paragraph numbering system to track
105
- /// the current nesting level for proper indentation and numbering.
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.
106
63
  ///
107
- /// - level (int): Paragraph nesting level to set
64
+ /// - body (content): Paragraph content to format
65
+ /// - level (int): Paragraph nesting level (0-based)
66
+ /// - level-counts (dictionary): Current counter values per level
108
67
  /// -> content
109
- #let SET_PAR_LEVEL(level) = {
110
- context {
111
- PAR_LEVEL_STATE.update(level)
112
- if level == 0 {
113
- counter(paragraph-config.counter-prefix + str(level + 1)).update(1)
114
- }
115
- }
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]
116
74
  }
117
75
 
118
- /// Creates a formatted paragraph with automatic numbering and indentation.
119
- ///
120
- /// Generates a properly formatted paragraph with AFH 33-337 compliant numbering,
121
- /// indentation, and spacing. Automatically manages counter incrementation and
122
- /// nested paragraph state. Used internally by the body rendering system.
76
+ /// Formats a continuation paragraph within a multi-block list item.
123
77
  ///
124
- /// Features:
125
- /// - Automatic paragraph number generation and formatting
126
- /// - Proper indentation based on nesting level via direct width measurement
127
- /// - Counter management for hierarchical numbering
128
- /// - Widow/orphan prevention settings
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.
129
82
  ///
130
- /// - content (content): Paragraph content to format
83
+ /// - body (content): Continuation paragraph content to format
84
+ /// - level (int): Paragraph nesting level (0-based)
85
+ /// - level-counts (dictionary): Current counter values per level
131
86
  /// -> content
132
- #let memo-par(content) = context {
133
- let level = PAR_LEVEL_STATE.get()
134
- let paragraph-number = generate-paragraph-number(level)
135
- // Reset child level counter
136
- counter(paragraph-config.counter-prefix + str(level + 1)).update(1)
137
- let indent-width = calculate-paragraph-indent(level)
138
-
139
-
140
- // Number + two spaces + content, with left padding for nesting
141
- //pad(left: indent-width, paragraph-number + " " + content)
142
- [#h(indent-width)#paragraph-number#" "#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]
143
95
  }
144
96
 
145
97
  // =============================================================================
@@ -160,10 +112,11 @@
160
112
  NEST_UP.update(0)
161
113
  let IS_HEADING = state("IS_HEADING")
162
114
  IS_HEADING.update(false)
163
- // Initialize level counters to 1 (Typst counters default to 0)
164
- for i in range(0, 5) {
165
- counter(paragraph-config.counter-prefix + "0").update(1)
166
- }
115
+ // Tracks whether the next paragraph is the first block in a list item.
116
+ // When true, the next `show par` captures a numbered item; subsequent
117
+ // paragraphs within the same item are continuations (no new number).
118
+ let ITEM_FIRST_PAR = state("ITEM_FIRST_PAR")
119
+ ITEM_FIRST_PAR.update(false)
167
120
 
168
121
  // The first pass parses paragraphs, list items, etc. into standardized arrays
169
122
  let first_pass = {
@@ -171,18 +124,29 @@
171
124
  show par: p => context {
172
125
  let nest_level = NEST_DOWN.get().at(0) - NEST_UP.get().at(0)
173
126
  let is_heading = IS_HEADING.get()
127
+ let is_first_par = ITEM_FIRST_PAR.get()
128
+
129
+ // Determine if this is a continuation block within a multi-block list item.
130
+ // A continuation is a non-first paragraph inside a list item (nest_level > 0).
131
+ let is_continuation = nest_level > 0 and not is_first_par
174
132
 
175
133
  PAR_BUFFER.update(pars => {
176
- // Item tuple: (content, nest_level, is_heading, is_table)
177
- pars.push((text([#p.body]), nest_level, is_heading, false))
134
+ // Item tuple: (content, nest_level, is_heading, is_table, is_continuation)
135
+ pars.push((text([#p.body]), nest_level, is_heading, false, is_continuation))
178
136
  pars
179
137
  })
138
+
139
+ // After the first paragraph of a list item, mark subsequent ones as continuations
140
+ if nest_level > 0 and is_first_par {
141
+ ITEM_FIRST_PAR.update(false)
142
+ }
143
+
180
144
  p
181
145
  }
182
146
  // Collect tables — captured as-is without paragraph numbering
183
147
  show table: t => context {
184
148
  PAR_BUFFER.update(pars => {
185
- pars.push((t, -1, false, true))
149
+ pars.push((t, -1, false, true, false))
186
150
  pars
187
151
  })
188
152
  t
@@ -199,11 +163,13 @@
199
163
  // layout convergence issues with many list items
200
164
  show enum.item: it => {
201
165
  NEST_DOWN.step()
166
+ ITEM_FIRST_PAR.update(true)
202
167
  [#parbreak()#it.body#parbreak()]
203
168
  NEST_UP.step()
204
169
  }
205
170
  show list.item: it => {
206
171
  NEST_DOWN.step()
172
+ ITEM_FIRST_PAR.update(true)
207
173
  [#parbreak()#it.body#parbreak()]
208
174
  NEST_UP.step()
209
175
  }
@@ -234,17 +200,34 @@
234
200
  //Second pass: consume par buffer
235
201
  //
236
202
  // PAR_BUFFER item tuple layout:
237
- // item.at(0) — content : the paragraph body or table element
238
- // item.at(1) — nest_level : nesting depth (−1 for tables)
239
- // item.at(2) — is_heading : bool, true if item is a heading paragraph
240
- // item.at(3) — is_table : bool, true if item is a table element
203
+ // item.at(0) — content : the paragraph body or table element
204
+ // item.at(1) — nest_level : nesting depth (−1 for tables)
205
+ // item.at(2) — is_heading : bool, true if item is a heading paragraph
206
+ // item.at(3) — is_table : bool, true if item is a table element
207
+ // item.at(4) — is_continuation : bool, true if this is a continuation block
208
+ // within a multi-block list item
241
209
  let ITEM_IS_TABLE = 3
210
+ let ITEM_IS_CONTINUATION = 4
242
211
  context {
243
212
  let heading_buffer = none
244
- // Tables do not count as paragraphs for AFH 33-337 §2 numbering purposes
245
- let par_count = PAR_BUFFER.get().filter(item => not item.at(ITEM_IS_TABLE, default: false)).len()
213
+ // Tables and continuation blocks do not count as distinct paragraphs
214
+ // for AFH 33-337 §2 numbering purposes
215
+ let par_count = PAR_BUFFER.get().filter(item =>
216
+ not item.at(ITEM_IS_TABLE, default: false)
217
+ and not item.at(ITEM_IS_CONTINUATION, default: false)
218
+ ).len()
246
219
  let items = PAR_BUFFER.get()
247
220
  let total_count = items.len()
221
+
222
+ // Track paragraph numbers per level manually to avoid nested-context
223
+ // counter propagation issues. Dictionary maps level index (as string)
224
+ // to the current counter value at that level.
225
+ let max-levels = paragraph-config.numbering-formats.len()
226
+ let level-counts = (:)
227
+ for lvl in range(max-levels) {
228
+ level-counts.insert(str(lvl), 1)
229
+ }
230
+
248
231
  let i = 0
249
232
  for item in items {
250
233
  i += 1
@@ -260,6 +243,7 @@
260
243
  let par_content = item.at(0)
261
244
  let nest_level = item.at(1)
262
245
  let is_heading = item.at(2)
246
+ let is_continuation = item.at(ITEM_IS_CONTINUATION, default: false)
263
247
 
264
248
  // Prepend heading as bolded sentence
265
249
  if heading_buffer != none {
@@ -271,25 +255,47 @@
271
255
  }
272
256
 
273
257
  let final_par = {
274
- if auto-numbering {
258
+ if is_continuation {
259
+ // Continuation block within a multi-block list item:
260
+ // indent to align with preceding numbered paragraph's text, no new number.
261
+ // level-counts still holds the value of the preceding numbered paragraph.
262
+ if auto-numbering {
263
+ format-continuation-par(par_content, nest_level, level-counts)
264
+ } else if nest_level > 0 {
265
+ format-continuation-par(par_content, nest_level - 1, level-counts)
266
+ } else {
267
+ par_content
268
+ }
269
+ } else if auto-numbering {
275
270
  if par_count > 1 {
276
271
  // Apply paragraph numbering per AFH 33-337 §2
277
- SET_PAR_LEVEL(nest_level)
278
- let paragraph = memo-par(par_content)
279
- paragraph
272
+ let par = format-numbered-par(par_content, nest_level, level-counts)
273
+ // Advance counter for this level and reset child levels
274
+ level-counts.insert(str(nest_level), level-counts.at(str(nest_level)) + 1)
275
+ for child in range(nest_level + 1, max-levels) {
276
+ level-counts.insert(str(child), 1)
277
+ }
278
+ par
280
279
  } else {
281
280
  // AFH 33-337 §2: "A single paragraph is not numbered"
282
- // Return body content wrapped in block (like numbered case, but without numbering)
283
281
  par_content
284
282
  }
285
283
  } else {
286
284
  // Unnumbered mode: only explicitly nested items (enum/list) get numbered
287
285
  if nest_level > 0 {
288
- SET_PAR_LEVEL(nest_level - 1)
289
- let paragraph = memo-par(par_content)
290
- paragraph
286
+ let effective_level = nest_level - 1
287
+ let par = format-numbered-par(par_content, effective_level, level-counts)
288
+ level-counts.insert(str(effective_level), level-counts.at(str(effective_level)) + 1)
289
+ for child in range(effective_level + 1, max-levels) {
290
+ level-counts.insert(str(child), 1)
291
+ }
292
+ par
291
293
  } else {
292
294
  // Base-level paragraphs are flush left with no numbering
295
+ // Reset all child level counters so subsequent list items restart at 1
296
+ for child in range(max-levels) {
297
+ level-counts.insert(str(child), 1)
298
+ }
293
299
  par_content
294
300
  }
295
301
  }
@@ -23,10 +23,9 @@
23
23
  date: none,
24
24
  // Format of indorsement: "standard" (same page), "informal" (no header), or "separate_page" (starts on new page)
25
25
  format: "standard",
26
- // Show the APPROVED / DISAPPROVED action line. Default: false.
27
- show_action: false,
28
- // Approval decision: none (no decision), "approved", or "disapproved".
29
- action: none,
26
+ // Approval action: "none" (default, no action line displayed), "approve", or "disapprove".
27
+ // When set to "approve" or "disapprove", the action line is displayed with the selected option circled.
28
+ action: "none",
30
29
  content,
31
30
  ) = {
32
31
  // Validate format parameter
@@ -91,8 +90,8 @@
91
90
  blank-line()
92
91
  }
93
92
 
94
- // Show action line if explicitly requested or if an action decision is set
95
- if show_action or action != none {
93
+ // Show action line only when an action decision is set (not "none")
94
+ if action != "none" {
96
95
  render-action-line(action)
97
96
  }
98
97
 
@@ -144,20 +144,30 @@
144
144
  // =============================================================================
145
145
  // ACTION LINE RENDERING
146
146
  // =============================================================================
147
- // Renders the APPROVED / DISAPPROVED action line for indorsement memos.
148
- // action: none = both plain (no decision yet), "approved" = bold APPROVED /
149
- // strike DISAPPROVED, "disapproved" = strike APPROVED / bold DISAPPROVED.
150
- // Visibility is controlled by the caller (show_action / action != none).
147
+ // Renders the APPROVE / DISAPPROVE action line for indorsement memos.
148
+ // action: "none" = no action line displayed (hidden), "approve" = APPROVE circled,
149
+ // "disapprove" = DISAPPROVE circled. The action line is only rendered when
150
+ // action is "approve" or "disapprove".
151
151
 
152
152
  #let render-action-line(action) = {
153
153
  assert(
154
- action in (none, "approved", "disapproved"),
155
- message: "action must be none, \"approved\", or \"disapproved\"",
154
+ action in ("none", "approve", "disapprove"),
155
+ message: "action must be \"none\", \"approve\", or \"disapprove\"",
156
156
  )
157
157
  blank-line()
158
- let approved-text = if action == "approved" { strong[APPROVED] } else if action == "disapproved" { strike[APPROVED] } else { [APPROVED] }
159
- let disapproved-text = if action == "disapproved" { strong[DISAPPROVED] } else if action == "approved" { strike[DISAPPROVED] } else { [DISAPPROVED] }
160
- [#approved-text / #disapproved-text]
158
+ // Circle the selected option using a box with rounded corners
159
+ // Use baseline parameter to maintain vertical text alignment
160
+ let approve-text = if action == "approve" {
161
+ box(stroke: 0.5pt + black, radius: 2pt, inset: 2pt, baseline: 2pt)[APPROVE]
162
+ } else {
163
+ [APPROVE]
164
+ }
165
+ let disapprove-text = if action == "disapprove" {
166
+ box(stroke: 0.5pt + black, radius: 2pt, inset: 2pt, baseline: 2pt)[DISAPPROVE]
167
+ } else {
168
+ [DISAPPROVE]
169
+ }
170
+ [#approve-text / #disapprove-text]
161
171
  }
162
172
 
163
173
  // =============================================================================
@@ -1,17 +1,27 @@
1
1
  [package]
2
2
  name = "tonguetoquill-usaf-memo"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  compiler = "0.14.0"
5
5
  entrypoint = "src/lib.typ"
6
6
  repository = "https://github.com/nibsbin/tonguetoquill-usaf-memo"
7
7
  authors = ["Nibs"]
8
8
  license = "MIT"
9
9
  description = "Typeset memos that are fully compliant with AFH 33-337 'The Tongue and Quill'."
10
- keywords = ["USAF", "Air Force", "memorandum", "memo", "military", "AFH 33-337", "official", "correspondence", "formatting"]
10
+ keywords = [
11
+ "USAF",
12
+ "Air Force",
13
+ "memorandum",
14
+ "memo",
15
+ "military",
16
+ "AFH 33-337",
17
+ "official",
18
+ "correspondence",
19
+ "formatting",
20
+ ]
11
21
  categories = ["office", "report"]
12
22
  disciplines = ["politics", "law", "business", "engineering", "education"]
13
23
 
14
24
  [template]
15
25
  path = "template"
16
26
  entrypoint = "usaf-template.typ"
17
- thumbnail = "template/assets/thumbnail.png"
27
+ thumbnail = "template/assets/thumbnail.png"
@@ -1,5 +1,5 @@
1
1
  #import "@local/quillmark-helper:0.1.0": data, eval-markup, parse-date
2
- #import "@preview/tonguetoquill-usaf-memo:1.0.0": backmatter, frontmatter, indorsement, mainmatter
2
+ #import "@preview/tonguetoquill-usaf-memo:1.1.0": backmatter, frontmatter, indorsement, mainmatter
3
3
 
4
4
  // Frontmatter configuration
5
5
  #show: frontmatter.with(
@@ -68,7 +68,6 @@
68
68
  format: card.at("format", default: "standard"),
69
69
  ..if "date" in card { (date: card.date) },
70
70
  ..if "action" in card { (action: card.action) },
71
- ..if "show_action" in card { (show_action: card.show_action) },
72
71
  )[
73
72
  #eval-markup(card.BODY)
74
73
  ]