@airmark/quiver 0.20.0 → 0.20.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.
@@ -1,14 +1,148 @@
1
- // form.typ (generated � do not edit)
1
+ // form.typ (generated — do not edit)
2
2
  #import "lib.typ": render-form
3
3
 
4
4
  #let form(
5
5
  debug: false,
6
-
6
+ mode_pmv: false, // checkbox
7
+ mode_airplane: false, // checkbox
8
+ mode_bus: false, // checkbox
9
+ mode_train: false, // checkbox
10
+ mode_motorcycle: false, // checkbox
11
+ mode_other: false, // checkbox
12
+ departure_date: "", // text
13
+ final_destination: "", // text
14
+ itin_date_1: "", // text
15
+ itin_departure_point_1: "", // text
16
+ itin_arrival_point_1: "", // text
17
+ itin_rest_length_1: "", // text
18
+ itin_mileage_1: "", // text
19
+ itin_date_2: "", // text
20
+ itin_departure_point_2: "", // text
21
+ itin_arrival_point_2: "", // text
22
+ itin_rest_length_2: "", // text
23
+ itin_mileage_2: "", // text
24
+ itin_date_3: "", // text
25
+ itin_departure_point_3: "", // text
26
+ itin_arrival_point_3: "", // text
27
+ itin_rest_length_3: "", // text
28
+ itin_mileage_3: "", // text
29
+ itin_date_4: "", // text
30
+ itin_departure_point_4: "", // text
31
+ itin_arrival_point_4: "", // text
32
+ itin_rest_length_4: "", // text
33
+ itin_mileage_4: "", // text
34
+ itin_date_5: "", // text
35
+ itin_departure_point_5: "", // text
36
+ itin_arrival_point_5: "", // text
37
+ itin_rest_length_5: "", // text
38
+ itin_mileage_5: "", // text
39
+ itin_date_6: "", // text
40
+ itin_departure_point_6: "", // text
41
+ itin_arrival_point_6: "", // text
42
+ itin_rest_length_6: "", // text
43
+ itin_mileage_6: "", // text
44
+ itin_date_7: "", // text
45
+ itin_departure_point_7: "", // text
46
+ itin_arrival_point_7: "", // text
47
+ itin_rest_length_7: "", // text
48
+ itin_mileage_7: "", // text
49
+ itin_date_8: "", // text
50
+ itin_departure_point_8: "", // text
51
+ itin_arrival_point_8: "", // text
52
+ itin_rest_length_8: "", // text
53
+ itin_mileage_8: "", // text
54
+ itin_date_9: "", // text
55
+ itin_departure_point_9: "", // text
56
+ itin_arrival_point_9: "", // text
57
+ itin_rest_length_9: "", // text
58
+ itin_mileage_9: "", // text
59
+ itin_date_10: "", // text
60
+ itin_departure_point_10: "", // text
61
+ itin_arrival_point_10: "", // text
62
+ itin_rest_length_10: "", // text
63
+ itin_mileage_10: "", // text
64
+ dept_flight: "", // text
65
+ arrival_flight: "", // text
66
+ notes: "", // text
67
+ organization: "", // text
68
+ briefed_date: "", // text
69
+ briefee_name: "", // text
70
+ briefee_grade: "", // text
71
+ briefer_name: "", // text
72
+ briefer_grade: "", // text
73
+ emergency_contact: "", // text
7
74
  ) = render-form(
8
75
  schema: json("FIELDS.json"),
9
76
  backgrounds: ("page1.png",),
10
77
  values: (
11
- ,
78
+ "mode_pmv": mode_pmv,
79
+ "mode_airplane": mode_airplane,
80
+ "mode_bus": mode_bus,
81
+ "mode_train": mode_train,
82
+ "mode_motorcycle": mode_motorcycle,
83
+ "mode_other": mode_other,
84
+ "departure_date": departure_date,
85
+ "final_destination": final_destination,
86
+ "itin_date_1": itin_date_1,
87
+ "itin_departure_point_1": itin_departure_point_1,
88
+ "itin_arrival_point_1": itin_arrival_point_1,
89
+ "itin_rest_length_1": itin_rest_length_1,
90
+ "itin_mileage_1": itin_mileage_1,
91
+ "itin_date_2": itin_date_2,
92
+ "itin_departure_point_2": itin_departure_point_2,
93
+ "itin_arrival_point_2": itin_arrival_point_2,
94
+ "itin_rest_length_2": itin_rest_length_2,
95
+ "itin_mileage_2": itin_mileage_2,
96
+ "itin_date_3": itin_date_3,
97
+ "itin_departure_point_3": itin_departure_point_3,
98
+ "itin_arrival_point_3": itin_arrival_point_3,
99
+ "itin_rest_length_3": itin_rest_length_3,
100
+ "itin_mileage_3": itin_mileage_3,
101
+ "itin_date_4": itin_date_4,
102
+ "itin_departure_point_4": itin_departure_point_4,
103
+ "itin_arrival_point_4": itin_arrival_point_4,
104
+ "itin_rest_length_4": itin_rest_length_4,
105
+ "itin_mileage_4": itin_mileage_4,
106
+ "itin_date_5": itin_date_5,
107
+ "itin_departure_point_5": itin_departure_point_5,
108
+ "itin_arrival_point_5": itin_arrival_point_5,
109
+ "itin_rest_length_5": itin_rest_length_5,
110
+ "itin_mileage_5": itin_mileage_5,
111
+ "itin_date_6": itin_date_6,
112
+ "itin_departure_point_6": itin_departure_point_6,
113
+ "itin_arrival_point_6": itin_arrival_point_6,
114
+ "itin_rest_length_6": itin_rest_length_6,
115
+ "itin_mileage_6": itin_mileage_6,
116
+ "itin_date_7": itin_date_7,
117
+ "itin_departure_point_7": itin_departure_point_7,
118
+ "itin_arrival_point_7": itin_arrival_point_7,
119
+ "itin_rest_length_7": itin_rest_length_7,
120
+ "itin_mileage_7": itin_mileage_7,
121
+ "itin_date_8": itin_date_8,
122
+ "itin_departure_point_8": itin_departure_point_8,
123
+ "itin_arrival_point_8": itin_arrival_point_8,
124
+ "itin_rest_length_8": itin_rest_length_8,
125
+ "itin_mileage_8": itin_mileage_8,
126
+ "itin_date_9": itin_date_9,
127
+ "itin_departure_point_9": itin_departure_point_9,
128
+ "itin_arrival_point_9": itin_arrival_point_9,
129
+ "itin_rest_length_9": itin_rest_length_9,
130
+ "itin_mileage_9": itin_mileage_9,
131
+ "itin_date_10": itin_date_10,
132
+ "itin_departure_point_10": itin_departure_point_10,
133
+ "itin_arrival_point_10": itin_arrival_point_10,
134
+ "itin_rest_length_10": itin_rest_length_10,
135
+ "itin_mileage_10": itin_mileage_10,
136
+ "dept_flight": dept_flight,
137
+ "arrival_flight": arrival_flight,
138
+ "notes": notes,
139
+ "organization": organization,
140
+ "briefed_date": briefed_date,
141
+ "briefee_name": briefee_name,
142
+ "briefee_grade": briefee_grade,
143
+ "briefer_name": briefer_name,
144
+ "briefer_grade": briefer_grade,
145
+ "emergency_contact": emergency_contact,
12
146
  ),
13
147
  debug: debug,
14
148
  )
@@ -1,6 +1,115 @@
1
1
  // Formalizer Engine – rendering engine
2
2
  // Renders pixel-perfect PDF form replicas from a PyMuPDF-extracted schema.
3
3
 
4
+ /// Global configuration for text rendering.
5
+ /// Adjust these to change the overall form text appearance.
6
+ #let FORM_MAX_TEXT_SIZE = 14pt
7
+ #let FORM_MIN_TEXT_SIZE = 6pt
8
+ #let FORM_MIN_CHARS_PER_LINE = 7
9
+
10
+ /// Coerce any user-supplied scalar value to a plain string.
11
+ /// Accepts the same types as Typst's built-in str(), plus content –
12
+ /// content is extracted via repr() with its surrounding square brackets stripped.
13
+ /// This lets callers write `[Hello]` (content) instead of `"Hello"` (string).
14
+ #let to-str(value) = {
15
+ if type(value) == content {
16
+ let r = repr(value)
17
+ // repr([hello]) → "[hello]" — strip the outer square brackets
18
+ if r.starts-with("[") and r.ends-with("]") {
19
+ r.slice(1, r.len() - 1)
20
+ } else {
21
+ r
22
+ }
23
+ } else {
24
+ str(value)
25
+ }
26
+ }
27
+
28
+ /// True when a schema field name corresponds to a date overlay.
29
+ #let is-date-field(name) = {
30
+ name.ends-with("_date") or name.starts-with("itin_date_")
31
+ }
32
+
33
+ /// Format a date value for display as `DD Mon YYYY`.
34
+ #let format-date(value) = {
35
+ if value == none {
36
+ none
37
+ } else if type(value) == datetime {
38
+ value.display("[day padding:zero] [month repr:short] [year]")
39
+ } else if type(value) == str and value == "" {
40
+ ""
41
+ } else {
42
+ str(value)
43
+ }
44
+ }
45
+
46
+ /// Should this field shrink text to a single line rather than word-wrap?
47
+ /// True for short/narrow fields with brief content (grades, ranks, dates).
48
+ /// `display` may be a string or content; char-count is skipped for content.
49
+ #let should-shrink-to-fit(display, width, height) = {
50
+ let aspect = width / height
51
+ // content has no .len(); treat as "long" so only the dimension conditions apply
52
+ let char-count = if type(display) == str { display.len() } else { 99 }
53
+ char-count <= 10 or height < 20pt or aspect > 4.0
54
+ }
55
+
56
+ /// Render a text-like field with shrink-to-fit and word-wrap fallback.
57
+ #let render-text-field(display, width, height, x-inset, y-inset) = {
58
+ set par(leading: 0.25em)
59
+ context {
60
+ let avail-w = width - 2 * x-inset
61
+ let avail-h = height - 2 * y-inset
62
+ let shrink = should-shrink-to-fit(display, width, height)
63
+
64
+ let final-size = FORM_MIN_TEXT_SIZE
65
+ let current = FORM_MAX_TEXT_SIZE
66
+ let step = 0.5pt
67
+
68
+ while current >= FORM_MIN_TEXT_SIZE {
69
+ let m = if shrink {
70
+ // Measure as a single line (no width constraint → no wrapping)
71
+ measure(text(size: current, display))
72
+ } else {
73
+ // Measure with wrapping within the available width
74
+ measure(block(width: avail-w, text(size: current, display)))
75
+ }
76
+ let char-m = measure(text(size: current, "0" * FORM_MIN_CHARS_PER_LINE))
77
+
78
+ if m.width <= avail-w and m.height <= avail-h and char-m.width <= avail-w {
79
+ final-size = current
80
+ break
81
+ }
82
+ current = current - step
83
+ }
84
+
85
+ // Fallback: if shrink-to-fit hit min size and still overflows,
86
+ // re-try with word-wrap enabled as a last resort.
87
+ if shrink and current < FORM_MIN_TEXT_SIZE {
88
+ current = FORM_MAX_TEXT_SIZE
89
+ while current >= FORM_MIN_TEXT_SIZE {
90
+ let m = measure(block(width: avail-w, text(size: current, display)))
91
+ let char-m = measure(text(size: current, "0" * FORM_MIN_CHARS_PER_LINE))
92
+ if m.height <= avail-h and char-m.width <= avail-w {
93
+ final-size = current
94
+ break
95
+ }
96
+ current = current - step
97
+ }
98
+ }
99
+
100
+ // Default to vertically centered. Only top-align for tall "text areas".
101
+ let vert-align = if height >= 40pt { top } else { horizon }
102
+
103
+ box(
104
+ width: width,
105
+ height: height,
106
+ clip: true,
107
+ inset: (x: x-inset, y: y-inset),
108
+ align(left + vert-align, text(size: final-size, display)),
109
+ )
110
+ }
111
+ }
112
+
4
113
  /// Render a single field's content overlay.
5
114
  ///
6
115
  /// - field-type (str): normalised lowercase type
@@ -10,29 +119,42 @@
10
119
  /// - field (dictionary): raw field entry from the schema
11
120
  #let render-field(field-type, value, width, height, field) = {
12
121
  if field-type == "text" {
13
- if value != none and str(value) != "" {
14
- context {
15
- let default-size = height * 0.6
16
- let min-size = 6pt
17
- let x-inset = 1.5pt
18
- let m = measure(text(size: default-size, str(value)))
19
- let scale = calc.min(1.0, (width - 2 * x-inset) / m.width)
20
- let final-size = calc.max(min-size, default-size * scale)
21
- box(
22
- width: width,
23
- height: height,
24
- clip: true,
25
- inset: (x: x-inset),
26
- align(left + horizon, text(size: final-size, str(value))),
27
- )
122
+ if value != none {
123
+ let display = if is-date-field(field.name) { format-date(value) } else { value }
124
+ // Pass content through directly so styling (bold, italic, …) is preserved;
125
+ // for plain strings, skip empty values.
126
+ if type(display) == content {
127
+ render-text-field(display, width, height, 1.5pt, 1pt)
128
+ } else if str(display) != "" {
129
+ render-text-field(str(display), width, height, 1.5pt, 1pt)
28
130
  }
29
131
  }
30
132
  } else if field-type == "checkbox" {
31
133
  if value == true {
134
+ // Drawn as vector strokes rather than a glyph so the mark renders
135
+ // regardless of whether the active font carries U+2713.
136
+ let s = calc.min(width, height) * 0.78
137
+ let t = calc.max(s * 0.16, 1pt)
138
+ let mark-stroke = (paint: black, thickness: t, cap: "round", join: "round")
32
139
  box(
33
140
  width: width,
34
141
  height: height,
35
- align(center + horizon, text(size: height * 0.8, "✓")),
142
+ align(center + horizon, box(
143
+ width: s,
144
+ height: s,
145
+ {
146
+ place(line(
147
+ start: (0.12 * s, 0.52 * s),
148
+ end: (0.40 * s, 0.80 * s),
149
+ stroke: mark-stroke,
150
+ ))
151
+ place(line(
152
+ start: (0.40 * s, 0.80 * s),
153
+ end: (0.88 * s, 0.16 * s),
154
+ stroke: mark-stroke,
155
+ ))
156
+ },
157
+ )),
36
158
  )
37
159
  }
38
160
  } else if field-type == "radio" {
@@ -47,31 +169,18 @@
47
169
  )
48
170
  }
49
171
  } else if field-type == "combobox" or field-type == "listbox" {
50
- let display = if value != none { str(value) } else { "" }
51
- // Resolve export value → display label when options are present
172
+ // Default: render the raw value, preserving content styling if present.
173
+ // Override with the schema's display label when the export value matches.
174
+ let display = value
52
175
  if field.at("options", default: none) != none and value != none {
53
176
  for opt in field.options {
54
- if str(opt.at(0)) == str(value) {
177
+ if str(opt.at(0)) == to-str(value) {
55
178
  display = str(opt.at(1))
56
179
  }
57
180
  }
58
181
  }
59
- if display != "" {
60
- context {
61
- let default-size = height * 0.6
62
- let min-size = 6pt
63
- let x-inset = 2pt
64
- let m = measure(text(size: default-size, display))
65
- let scale = calc.min(1.0, (width - 2 * x-inset) / m.width)
66
- let final-size = calc.max(min-size, default-size * scale)
67
- box(
68
- width: width,
69
- height: height,
70
- clip: true,
71
- inset: (x: x-inset),
72
- align(left + horizon, text(size: final-size, display)),
73
- )
74
- }
182
+ if display != none and to-str(display) != "" {
183
+ render-text-field(display, width, height, 2pt, 1pt)
75
184
  }
76
185
  }
77
186
  }
@@ -86,10 +195,10 @@
86
195
  if group-value == none { return false }
87
196
  let ev = field.at("export_value", default: none)
88
197
  if ev != none {
89
- return str(ev) == str(group-value)
198
+ return str(ev) == to-str(group-value)
90
199
  }
91
200
  // Fallback: match against the stringified index
92
- str(index-in-group) == str(group-value)
201
+ str(index-in-group) == to-str(group-value)
93
202
  }
94
203
 
95
204
  /// Draw a debug overlay rectangle with a label for a field.
@@ -1,86 +1,63 @@
1
1
  #import "@local/quillmark-helper:0.1.0": data
2
+ #import "@local/daf4392page2-pkg:0.1.0": form
2
3
 
3
- // `type: date` fields arrive as Typst `datetime`; overlay text must be plain strings.
4
- #let show-date(v) = {
5
- if v == none { "" }
6
- else if type(v) == datetime { v.display("[month padding:none]/[day padding:none]/[year]") }
7
- else { str(v) }
8
- }
9
-
10
- #set page(width: 8.5in, height: 11in, margin: 0in)
11
- #set text(font: "Arimo", size: 10pt)
4
+ #set text(font: "Arimo")
12
5
 
13
- // Background
14
- #place(image("assets/page1.png", width: 100%, height: 100%))
6
+ // Map snake_case Quill `data` keys to generated `form.typ` parameter names.
7
+ #let vals = (:)
15
8
 
16
- #let tf(dx, dy, content) = place(dx: dx, dy: dy, content)
17
- #let check(dx, dy, is_checked) = if is_checked { place(dx: dx, dy: dy, text(weight: "bold", size: 12pt)[✓]) }
18
-
19
- // Mode of Transportation
9
+ // Mode of Transportation — enum string drives one of six checkboxes.
20
10
  #let mode = data.at("transportation_mode", default: "")
21
- #check(40pt, 67pt, mode == "pmv")
22
- #check(173pt, 67pt, mode == "airplane")
23
- #check(243pt, 67pt, mode == "bus")
24
- #check(286pt, 67pt, mode == "train")
25
- #check(336pt, 67pt, mode == "motorcycle")
26
- #check(411pt, 67pt, mode == "other")
11
+ #if mode != "" { vals.insert("mode_" + mode, true) }
27
12
 
28
- // Fields
29
- #tf(40pt, 100pt)[#show-date(data.at("departure_date", default: none))]
30
- #tf(123pt, 100pt)[#data.at("final_destination", default: "")]
13
+ // Header row
14
+ #if "departure_date" in data { vals.insert("departure_date", data.departure_date) }
15
+ #if "final_destination" in data { vals.insert("final_destination", data.final_destination) }
31
16
 
32
- // Itinerary Rows (via CARDS)
17
+ // Proposed Travel Itinerary — one card per row, up to 10 rows.
18
+ #let itin-cols = ("date", "departure_point", "arrival_point", "rest_length", "mileage")
33
19
  #{
34
20
  let row = 0
35
- let dy-start = 160pt
36
- let dy-step = 40pt
37
- if "CARDS" in data {
38
- for card in data.CARDS {
39
- if card.CARD == "itinerary" and row < 10 {
40
- let dy = dy-start + (row * dy-step)
41
- tf(80pt, dy)[#show-date(card.at("date", default: none))]
42
- tf(135pt, dy)[#card.at("departure_point", default: "")]
43
- tf(295pt, dy)[#card.at("arrival_point", default: "")]
44
- tf(450pt, dy)[#card.at("rest_length", default: "")]
45
- tf(515pt, dy)[#card.at("mileage", default: "")]
46
- row = row + 1
21
+ for card in data.at("CARDS", default: ()) {
22
+ if card.CARD == "itinerary" and row < 10 {
23
+ let n = str(row + 1)
24
+ for col in itin-cols {
25
+ let v = card.at(col, default: none)
26
+ let display = if col == "date" { v } else if v == none { "" } else { str(v) }
27
+ if display != none and display != "" { vals.insert("itin_" + col + "_" + n, display) }
47
28
  }
29
+ row = row + 1
48
30
  }
49
31
  }
50
32
  }
51
33
 
52
- // Flight Info
34
+ // Flight info — bold label prefixes a tentative/confirmed flight number.
53
35
  #{
54
- let d-flight = str(data.at("dept_flight_num", default: ""))
55
- if d-flight != "" { tf(350pt, 545pt)[#text(weight: "bold")[Dept Flight:] #d-flight] }
56
-
57
- let a-flight = str(data.at("arrival_flight_num", default: ""))
58
- if a-flight != "" { tf(450pt, 545pt)[#text(weight: "bold")[Arr Flight:] #a-flight] }
36
+ let d = str(data.at("dept_flight_num", default: ""))
37
+ if d != "" { vals.insert("dept_flight", [#text(weight: "bold")[Dept Flight:] #d]) }
38
+ let a = str(data.at("arrival_flight_num", default: ""))
39
+ if a != "" { vals.insert("arrival_flight", [#text(weight: "bold")[Arr Flight:] #a]) }
59
40
  }
60
41
 
61
42
  // Notes
62
- #tf(40pt, 565pt)[#block(width: 530pt)[#data.at("notes", default: "")]]
43
+ #if "notes" in data { vals.insert("notes", data.notes) }
63
44
 
64
45
  // Acknowledgements
65
- #tf(40pt, 620pt)[#data.at("organization", default: "")]
66
- #tf(500pt, 620pt)[#show-date(data.at("briefed_date", default: none))]
67
-
68
- #tf(40pt, 650pt)[#data.at("briefee_name", default: "")]
69
- #tf(270pt, 650pt)[#data.at("briefee_grade", default: "")]
70
-
71
- #tf(40pt, 680pt)[#data.at("briefer_name", default: "")]
72
- #tf(270pt, 680pt)[#data.at("briefer_grade", default: "")]
73
-
74
- // Emergency Contact
46
+ #if "organization" in data { vals.insert("organization", data.organization) }
47
+ #if "briefed_date" in data { vals.insert("briefed_date", data.briefed_date) }
48
+ #if "briefee_name" in data { vals.insert("briefee_name", data.briefee_name) }
49
+ #if "briefee_grade" in data { vals.insert("briefee_grade", data.briefee_grade) }
50
+ #if "briefer_name" in data { vals.insert("briefer_name", data.briefer_name) }
51
+ #if "briefer_grade" in data { vals.insert("briefer_grade", data.briefer_grade) }
52
+
53
+ // Emergency contact — bold label prefixes a "name: phone" pair.
75
54
  #{
76
- let emergency_name = str(data.at("emergency_contact_name", default: ""))
77
- let emergency_phone = str(data.at("emergency_contact_phone", default: ""))
78
- let emergency_contact = if emergency_name != "" and emergency_phone != "" {
79
- emergency_name + ": " + emergency_phone
80
- } else if emergency_name != "" {
81
- emergency_name
82
- } else {
83
- emergency_phone
55
+ let en = str(data.at("emergency_contact_name", default: ""))
56
+ let ep = str(data.at("emergency_contact_phone", default: ""))
57
+ let contact = if en != "" and ep != "" { en + ": " + ep } else if en != "" { en } else { ep }
58
+ if contact != "" {
59
+ vals.insert("emergency_contact", [#text(weight: "bold")[EMERGENCY CONTACT:] #contact])
84
60
  }
85
- tf(40pt, 700pt)[#text(size: 10pt, weight: "bold")[EMERGENCY CONTACT:] #text(size: 10pt)[#emergency_contact]]
86
61
  }
62
+
63
+ #form(..vals)
@@ -91,8 +91,17 @@ main:
91
91
  default: ""
92
92
  ui:
93
93
  group: Letterhead
94
+ compact: true
94
95
  description: Organizational motto at the bottom of the page.
95
96
 
97
+ freedom250:
98
+ type: boolean
99
+ default: true
100
+ ui:
101
+ group: Letterhead
102
+ compact: true
103
+ description: Show the America 250 / Freedom 250 commemorative emblem in the letterhead, mirrored opposite the seal.
104
+
96
105
  references:
97
106
  type: array
98
107
  default: []
@@ -19,6 +19,7 @@
19
19
  letterhead_caption: "[YOUR SQUADRON/UNIT NAME]",
20
20
  letterhead_seal: none,
21
21
  letterhead_seal_subtitle: none, // optional line under seal (9pt bold caps); ignored if no seal
22
+ letterhead_emblem: none, // optional image placed opposite the seal (right side)
22
23
  letterhead_font: DEFAULT_LETTERHEAD_FONTS,
23
24
  body_font: DEFAULT_BODY_FONTS,
24
25
  font_size: 12pt,
@@ -122,6 +123,7 @@
122
123
  letterhead_font,
123
124
  letterhead-seal: letterhead_seal,
124
125
  letterhead-seal-subtitle: letterhead_seal_subtitle,
126
+ letterhead-emblem: letterhead_emblem,
125
127
  )
126
128
 
127
129
  // AFH 33-337 "Date": "Place the date 1 inch from the right edge, 1.75 inches from the top"
@@ -20,6 +20,7 @@
20
20
  font,
21
21
  letterhead-seal: none,
22
22
  letterhead-seal-subtitle: none,
23
+ letterhead-emblem: none, // optional image placed opposite the seal (right side)
23
24
  ) = {
24
25
  font = ensure-array(font)
25
26
  title = ensure-string(title)
@@ -73,6 +74,17 @@
73
74
  seal-body,
74
75
  )
75
76
  }
77
+
78
+ if letterhead-emblem != none {
79
+ place(
80
+ right + top,
81
+ dx: 0.5in,
82
+ dy: -.5in,
83
+ block[
84
+ #fit-box(width: 2in, height: 1in, alignment: right + horizon)[#letterhead-emblem]
85
+ ],
86
+ )
87
+ }
76
88
  }
77
89
 
78
90
  // =============================================================================
@@ -15,6 +15,11 @@
15
15
  }
16
16
  ),
17
17
 
18
+ // Optional Freedom 250 emblem, placed opposite the seal
19
+ ..if data.at("freedom250", default: true) {
20
+ (letterhead_emblem: image("assets/freedom250.png"))
21
+ },
22
+
18
23
  // Date
19
24
  date: data.at("date", default: none),
20
25