@airmark/quiver 0.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airmark/quiver",
3
- "version": "0.32.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.24.0",
20
- "@quillmark/wasm": "^0.108.1",
21
- "quillkit": "0.5.1"
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",
@@ -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: plaintext
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:
@@ -195,14 +208,14 @@ main:
195
208
  attachments:
196
209
  type: array
197
210
  items:
198
- type: plaintext
211
+ type: richtext
199
212
  inline: true
200
213
  default: []
201
214
  example:
202
215
  - Attachment description, YYYY MMM DD
203
216
  ui:
204
217
  group: additional
205
- 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."
206
219
 
207
220
  memo_style:
208
221
  type: enum
@@ -223,14 +236,6 @@ main:
223
236
  compact: true
224
237
  description: Body text size in points; decimals allowed (e.g. 11.5).
225
238
 
226
- date:
227
- type: date
228
- default: ""
229
- ui:
230
- group: additional
231
- compact: true
232
- description: YYYY-MM-DD. Leave blank to use today's date.
233
-
234
239
  card_kinds:
235
240
  indorsement:
236
241
  description: Chain of routing endorsements. Each endorsement block adds an official response or forwarding action to the original memo.
@@ -266,6 +271,13 @@ card_kinds:
266
271
  group: addressing
267
272
  compact: true
268
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."
269
281
  format:
270
282
  type: enum
271
283
  values:
@@ -288,10 +300,3 @@ card_kinds:
288
300
  group: additional
289
301
  compact: true
290
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."
291
- date:
292
- type: date
293
- default: ""
294
- ui:
295
- group: additional
296
- compact: true
297
- 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."
@@ -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
- 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]
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 table element
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 "continuation"
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), and
234
- // another heading all fail the test, and each would otherwise carry the
235
- // heading's text somewhere it was not authored. Those emit the heading
236
- // on its own line, the treatment a heading before a table takes.
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 != "heading"
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.
@@ -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. The stride is measured from
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.
@@ -1,4 +1,6 @@
1
- #import "@local/quillmark-helper:0.1.0": data, display, form-field, signature-field
1
+ #import "@local/quillmark-helper:0.1.0": (
2
+ data, display, field-region, form-field, signature-field,
3
+ )
2
4
  #import "@local/tonguetoquill-usaf-memo:4.0.0": (
3
5
  backmatter, date-pattern, frontmatter, indorsement, mainmatter,
4
6
  )
@@ -41,9 +43,24 @@
41
43
  // but its ink would be born inside the package and carry no schema address.
42
44
  // `display` places the field's *content* projection instead: the glyphs are
43
45
  // born in the generated helper, so the memo date stays click-to-edit however
44
- // deep the package formats it. A blank date yields `none`, which is what
45
- // `frontmatter`'s `datetime.today()` fallback keys on.
46
- date: display("date", date-pattern(memo-style: memo_style)),
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
+ },
47
64
 
48
65
  // Receiver information
49
66
  memo_for: data.memo_for,