diff --git a/Makefile b/Makefile
index 8d563cf..8df740e 100644
--- a/Makefile
+++ b/Makefile
@@ -67,6 +67,13 @@ TESTS := $(wildcard tests/*.typ)
PDFS := $(EXAMPLES:.typ=.pdf)
PNGS := $(EXAMPLES_PNG:.typ=.png)
TEST_PDFS := $(patsubst tests/%.typ,examples/tests/%.pdf,$(TESTS))
+# Every test fixture imports `lib.typ` (transitively pulling in
+# everything under `internal/` and `sections/`), so renderer tweaks
+# there must invalidate the cached PDFs even though the per-fixture
+# `tests/*.typ` source itself hasn't changed. Without this dependency
+# CI rebuilds from scratch, sees byte drift, and trips the
+# "examples/tests/*.pdf in sync" guard.
+LIB_SOURCES := lib.typ $(wildcard internal/*.typ) $(wildcard sections/*.typ)
.PHONY: all cv example-full thumbnail preview-gif pdfs previews test-pdfs test test-template check clean help
@@ -132,7 +139,7 @@ test-pdfs: $(TEST_PDFS)
examples/tests:
mkdir -p $@
-examples/tests/%.pdf: tests/%.typ | examples/tests
+examples/tests/%.pdf: tests/%.typ $(LIB_SOURCES) | examples/tests
$(TYPST) compile --creation-timestamp 0 --root $(ROOT) $< $@
# Pattern rule: every examples/X.typ produces examples/X.pdf.
diff --git a/README.md b/README.md
index edaedd1..eaaaa4e 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
## Features
@@ -22,6 +22,7 @@
- **Six built-in accent palettes** (`teal`, `navy`, `crimson`, `forest`, `plum`, `charcoal`) plus any `rgb(...)` value.
- **Full label localisation** via inline dict or TOML file — every display string the template emits is overridable, with a worked Irish translation under [`examples/labels-ga.toml`](examples/labels-ga.toml).
- **PDF metadata baked in** — title, author, subject, keywords (auto-derived from skills), and document date populate from the same data dict.
+- **Optional header QR code** linking to `basics.url` (or any URL) — restores one click of digital-PDF affordance for printed CVs.
## Gallery
@@ -116,7 +117,7 @@ ISO 8601 date strings (`"2024"`, `"2024-06"`, `"2024-06-15"` — the JSON Resume
Top-level keys recognised: `basics`, `focusAreas`, `work`, `volunteer`, `skills`, `languages`, `education`, `certificates`, `awards`, `projects`, `publications`, `interests`, `references`, `meta` (PDF metadata only — see [PDF metadata](#pdf-metadata)). Sections with empty input are skipped — no orphan headings.
-`basics.url` (JSON Resume's "personal homepage" field) renders in the contact bar with the generic `link` icon, alongside `email`, `phone`, `location`, and `profiles`. It's distinct from a `basics.profiles` entry with `network: "Website"` (a profile *on* a third-party site); supply both if you want both rendered.
+`basics.url` (JSON Resume's "personal homepage" field) renders in the contact bar with the generic `link` icon, alongside `email`, `phone`, `location`, and `profiles`. It's distinct from a `basics.profiles` entry with `network: "Website"` (a profile *on* a third-party site); supply both if you want both rendered. The same `basics.url` also drives the header QR matrix when `preferences.qrCode: auto` is set — see [Header QR code](#header-qr-code-preferencesqrcode).
JSON Resume fields **accepted but not yet rendered** by this template:
@@ -151,6 +152,31 @@ basics: (
JSON Resume's spec calls for a URL here, but Typst does not fetch remote URLs at compile time — vendor the asset locally.
+### Header QR code (`preferences.qrCode`)
+
+Printed CVs lose the clickability of digital PDFs — a reader looking at a paper copy has to type the URL themselves. A QR matrix in the header rescues one link (the homepage), so a phone camera takes the reader straight there. Off by default; opt in via `preferences.qrCode`:
+
+```typst
+#alta(
+ (basics: (
+ name: "Jane Doe",
+ url: "https://janedoe.dev", // canonical home page
+ // …
+ )),
+ preferences: (qrCode: auto), // encode basics.url
+)
+```
+
+`preferences.qrCode` accepts three shapes:
+
+- `none` (default) — no QR rendered.
+- `auto` — encode `basics.url`. Panics if `basics.url` is missing or empty.
+- any non-empty string — treat it as the URL to encode directly. Useful when the printed CV should point at a tracked landing page that's distinct from the canonical `basics.url`.
+
+The QR sits on the side of the header opposite the portrait (so the default `imagePosition: "right"` layout puts the QR on the left). With no portrait, the QR still lands on the side opposite `imagePosition` — adding a photo later doesn't shift the QR. With `imagePosition: "center"` the QR pins the header's top-left corner regardless of `imageStackOrder`: it rides the photo row when the photo is on top, and the text row when the photo stacks below. The matrix inherits `preferences.accent` for its module colour and renders at roughly `3.5em` — small enough to stay out of the way, large enough to scan reliably at typical print DPIs. The matrix is also wrapped in `link()` so digital readers can click through to the same destination.
+
+QR generation is delegated to the [`@preview/zebra`](https://typst.app/universe/package/zebra) package — a single-file generator that emits native Typst vector paths. This is the only third-party Typst dependency `altacv` pulls in; it's fetched on first compile and cached thereafter.
+
Each per-section entry below follows JSON Resume's schema. Tables show the practical subset rendered. Where dates appear, `startDate` / `endDate` follow the same conventions (omit `endDate` → "Present"); `summary` accepts a string or `[...]` content for markup like emphasis.
### Work
@@ -315,6 +341,7 @@ Every theme, font, layout, and behaviour knob lives in `preferences`. Override a
| `imagePosition` | `"right"` | Portrait position in the header — `"left"` / `"right"` (two-column header) or `"center"` (own centred row, stacked with the text block). Ignored when no `basics.image`. |
| `imageStackOrder` | `"above"` | When `imagePosition` is `"center"`: `"above"` / `"below"` the name/label/contact block. Ignored otherwise. |
| `headerTextAlign` | `"left"` | Horizontal alignment of the header text (name, label, contact bar). One of `"left"`, `"right"`, `"center"`. Applies whether or not `basics.image` is set. |
+| `qrCode` | `none` | Renders a small accent-coloured QR matrix in the header opposite the portrait. `none` (default) skips it. `auto` encodes `basics.url` (panicking if it's missing or empty). Any non-empty string is encoded verbatim — handy for pointing a printed CV at a tracked landing page distinct from `basics.url`. Generation is delegated to [`@preview/zebra`](https://typst.app/universe/package/zebra), the only third-party dependency this package pulls in. See [Header QR code](#header-qr-code-preferencesqrcode) for layout details. |
| `uppercaseName` | `true` | When `true` (matching AltaCV's visual ancestor), `basics.name` renders in uppercase. Set to `false` for scripts where uppercase is a different glyph set (Turkish dotless-i, etc.), scripts with no case, or when the loud look isn't wanted. |
| `lastModifiedFooter` | `false` | When `true` and `meta.lastModified` is set, renders a small right-aligned `: ` line in the page footer (timestamp passed through verbatim). PDF metadata is enriched independently — see [PDF metadata](#pdf-metadata). |
| `referencesAvailableOnRequest` | `false` | When `true` and `references[]` is empty (or every entry has no `reference` quote), renders the conventional `labels.referencesAvailableOnRequest` line under the References heading instead of suppressing the section. When `false` (the default) an empty section is suppressed entirely, matching every other section. |
diff --git a/examples/preview-frames.typ b/examples/preview-frames.typ
index 52379fe..934f651 100644
--- a/examples/preview-frames.typ
+++ b/examples/preview-frames.typ
@@ -95,14 +95,17 @@
rightColumnSections: ("work", "projects", "publications", "awards"),
), (:)),
- // ── Frame 4 ─ crimson + portrait left + Irish labels ────────────
- // Image swung to the left of the header; section columns swap
- // emphasis (compact / support blocks lead, work + projects move
+ // ── Frame 4 ─ crimson + portrait left + QR right + Irish labels ─
+ // Image swung to the left of the header with a QR matrix mirrored
+ // on the right — the flipped twin of frame 6's QR layout, exercising
+ // the same feature under `imagePosition: "left"`. Section columns
+ // swap emphasis (compact / support blocks lead, work + projects move
// right). Section headings render in Irish via `labels-ga.toml`
// — the same data dict, just localised display strings.
(cv, base + (
accent: palettes.crimson,
imagePosition: "left",
+ qrCode: auto,
leftColumnSections: ("focusAreas", "skills", "languages", "education", "certificates", "awards"),
rightColumnSections: ("work", "projects"),
), ga-labels),
@@ -122,13 +125,16 @@
),
), (:)),
- // ── Frame 6 ─ plum + portrait right + short dates + education up ─
- // Image on the right (canonical default); compact European-style
- // `[day]/[month]/[year]` date format. Education + certificates
+ // ── Frame 6 ─ plum + portrait right + QR left + short dates + education up
+ // Image on the right (canonical default) and a QR matrix on the
+ // opposite side — a [QR | text | portrait] header showcasing the
+ // print-affordance feature on the standard layout. Compact European-
+ // style `[day]/[month]/[year]` date format. Education + certificates
// promoted to the left column alongside work; the right column
// becomes a compact support panel.
(cv, base + (
accent: palettes.plum,
+ qrCode: auto,
dateFormat: "[day]/[month]/[year]",
leftColumnSections: ("work", "education", "certificates"),
rightColumnSections: (
diff --git a/examples/preview.gif b/examples/preview.gif
index cfff664..0b23385 100644
Binary files a/examples/preview.gif and b/examples/preview.gif differ
diff --git a/examples/tests/centred_header_image.pdf b/examples/tests/centred_header_image.pdf
index 818ff46..c67a0b8 100644
Binary files a/examples/tests/centred_header_image.pdf and b/examples/tests/centred_header_image.pdf differ
diff --git a/examples/tests/header_qr_code.pdf b/examples/tests/header_qr_code.pdf
new file mode 100644
index 0000000..fa6ca6a
Binary files /dev/null and b/examples/tests/header_qr_code.pdf differ
diff --git a/internal/header.typ b/internal/header.typ
index bcd5d75..eea6f9b 100644
--- a/internal/header.typ
+++ b/internal/header.typ
@@ -8,6 +8,7 @@
#import "state.typ": _body_size_state, _accent_state, _emphasis_colour
#import "presets.typ": maps-providers
#import "icons.typ": icon, _profile_networks, _network_aliases
+#import "qr.typ": _resolve_qr_url, _qr_render
// JSON Resume's structured `location` dict collapsed to a single
// header line. `address`/`postalCode` round-trip but aren't rendered
@@ -122,6 +123,7 @@
link-contact-info: true,
maps-provider: maps-providers.google,
uppercase-name: true,
+ qr-code: none,
) = {
if image-position not in ("left", "right", "center") {
panic("imagePosition must be \"left\", \"right\", or \"center\", got: " + repr(image-position))
@@ -143,6 +145,7 @@
}
)
let link-config = _resolve_link_config(link-contact-info)
+ let qr-url = _resolve_qr_url(qr-code, basics)
context {
let body-size = _body_size_state.get()
let accent = _accent_state.get()
@@ -277,51 +280,79 @@
"basics.image must be a string path or bytes, got: " + repr(image-src),
)
}
- if has-image {
- // Swapping the column order moves the photo to the opposite
- // side without changing the alignment of the text within its
- // column — both branches keep `1fr` on the text side.
- let photo = _portrait(image-src, image-size)
- if image-position == "left" {
+ let photo = if has-image { _portrait(image-src, image-size) }
+ // 3.5em ≈ small enough to stay out of the way of the contact bar,
+ // large enough to remain scannable at typical print DPIs. The QR
+ // inherits `accent` so it sits with the rest of the header's
+ // coloured ornaments instead of fighting them with pure black.
+ let qr-size = 3.5 * body-size
+ let qr = if qr-url != none { _qr_render(qr-url, qr-size, accent) }
+
+ // Centred portrait: photo stacks on its own row above or below the
+ // text block. Full-width `block` centres the photo against the
+ // document width (a bare `align(center, box)` would centre against
+ // the default inline position). When a QR is present, the top row
+ // is wrapped in a `(auto, 1fr, auto)` grid — QR on the left,
+ // content centred page-wise in the 1fr column, `qr-size` spacer on
+ // the right — so the QR stays anchored to the header's top corner
+ // regardless of stack order. The inter-row gap is applied
+ // externally via `v(...)` so it survives the grid wrap (block
+ // spacing inside a grid cell is consumed by the cell, not the
+ // surrounding flow).
+ if image-position == "center" {
+ let centred-photo = if photo != none {
+ block(width: 100%, align(center, photo))
+ }
+ let with-qr(content) = if qr == none {
+ content
+ } else {
grid(
- columns: (auto, 1fr),
+ columns: (auto, 1fr, auto),
align: top,
column-gutter: 1em,
- photo,
- header-text,
+ qr, content, box(width: qr-size),
)
- } else if image-position == "right" {
+ }
+ let (top, bottom) = if centred-photo == none {
+ (header-text, none)
+ } else if image-stack-order == "above" {
+ (centred-photo, header-text)
+ } else {
+ (header-text, centred-photo)
+ }
+ with-qr(top)
+ if bottom != none {
+ v(0.8 * body-size)
+ bottom
+ }
+ } else {
+ // Horizontal layout: photo on the requested side, QR opposite.
+ // With no portrait, "opposite of imagePosition" still applies —
+ // a default (imagePosition: "right") CV with only a QR puts it
+ // on the left, matching where it would land if a photo were
+ // added later. Dropping absent ornaments (rather than rendering
+ // empty cells) avoids an extra 1em column-gutter on the
+ // surviving side.
+ let (left-ornament, right-ornament) = if image-position == "left" {
+ (photo, qr)
+ } else {
+ (qr, photo)
+ }
+ let cells = (
+ (left-ornament, auto),
+ (header-text, 1fr),
+ (right-ornament, auto),
+ ).filter(((cell, _)) => cell != none)
+ if cells.len() == 1 {
+ header-text
+ } else {
grid(
- columns: (1fr, auto),
+ columns: cells.map(((_, col)) => col),
align: top,
column-gutter: 1em,
- header-text,
- photo,
+ ..cells.map(((cell, _)) => cell),
)
- } else {
- // Centred: stack the photo on its own row above or below the
- // text block. The photo itself is a fixed-size `box` (inline-
- // level); a bare `align(center, box)` lays the box at the
- // default inline position because there's no block context
- // defining what to centre within. Wrap the centring `align`
- // in a full-width `block` so the alignment computes against
- // the document width, regardless of how the text above /
- // below is aligned.
- let centred-photo = block(
- spacing: 0.8 * body-size,
- width: 100%,
- align(center, photo),
- )
- if image-stack-order == "above" {
- centred-photo
- header-text
- } else {
- header-text
- centred-photo
- }
}
- } else {
- header-text
}
}
}
diff --git a/internal/layout.typ b/internal/layout.typ
index 333f08d..0198b52 100644
--- a/internal/layout.typ
+++ b/internal/layout.typ
@@ -112,6 +112,9 @@
// the centred portrait stacks above or below the header text block.
imageStackOrder: "above",
headerTextAlign: "left",
+ // `auto` encodes `basics.url`; any non-empty string is encoded
+ // verbatim. Backed by `@preview/zebra` via `internal/qr.typ`.
+ qrCode: none,
// PDF metadata (title / author) stays as-supplied regardless of
// this flag — see the comment above `set document(...)`.
uppercaseName: true,
diff --git a/internal/qr.typ b/internal/qr.typ
new file mode 100644
index 0000000..2b62539
--- /dev/null
+++ b/internal/qr.typ
@@ -0,0 +1,75 @@
+// Header QR matrix — opt-in via `preferences.qrCode`. Printed CVs lose
+// the clickability of digital PDFs; a QR code rescues that one link
+// the reader is most likely to follow (the homepage URL). Off by
+// default — turn it on with `preferences.qrCode: auto` (encode
+// `basics.url`) or any literal URL string.
+//
+// Generation is delegated to `@preview/zebra`, the only third-party
+// Typst dependency this package pulls in. Zebra emits native Typst
+// vector paths (not a rasterised image), so the matrix stays crisp at
+// any size and inherits a `fill` colour like any other shape. The
+// dependency is fetched on first compile and cached thereafter.
+//
+// This module owns three concerns and nothing else: validating the
+// preference value, resolving it against `basics.url`, and rendering
+// the matrix wrapped in `link()`. `internal/header.typ` consumes
+// `_qr_render` and composes the result into the header layout — the
+// header file doesn't import zebra directly, so swapping QR
+// implementations stays a one-file change.
+
+#import "@preview/zebra:0.1.0": qrcode
+
+// Validates `preferences.qrCode`. Accepted shapes:
+// - `none` — no QR rendered
+// - `auto` — encode `basics.url` at render time
+// - any non-empty string — encode that string verbatim
+// Anything else panics with a message anchored at the preferences
+// call site, so a typo (`qrCode: true`, `qrCode: 42`) surfaces up
+// front rather than as a render-time failure inside `qrcode(...)`.
+#let _check_qr_code(value) = {
+ if value == none or value == auto { return }
+ if type(value) != str {
+ panic(
+ "qrCode must be `none`, `auto`, or a URL string, got: " + repr(value),
+ )
+ }
+ if value == "" {
+ panic("qrCode must be a non-empty string when not `none` / `auto`.")
+ }
+}
+
+// Resolves the validated preference against `basics`. Returns the URL
+// string to encode, or `none` when no QR should render. Separated
+// from `_check_qr_code` because the `auto` lookup depends on `basics`
+// — only callable once `alta()` has the data dict in hand.
+#let _resolve_qr_url(qr-code, basics) = {
+ if qr-code == none { return none }
+ if qr-code != auto { return qr-code }
+ let url = basics.at("url", default: none)
+ if url == none {
+ panic(
+ "preferences.qrCode is `auto` but basics.url is missing. "
+ + "Set basics.url to the destination URL, or pass the URL "
+ + "directly via preferences.qrCode.",
+ )
+ }
+ if type(url) != str {
+ panic("basics.url must be a string, got: " + repr(url))
+ }
+ if url == "" {
+ panic(
+ "basics.url is empty; set it to the destination URL or remove preferences.qrCode.",
+ )
+ }
+ url
+}
+
+// `quiet-zone: 0` because the surrounding header padding already
+// supplies enough whitespace; zebra's default 4-module quiet zone
+// would shrink the dark matrix at the print size we target. The
+// `link()` wrap lets digital readers click through to the same
+// destination the QR encodes.
+#let _qr_render(url, size, fill) = link(
+ url,
+ qrcode(url, width: size, quiet-zone: 0, fill: fill),
+)
diff --git a/lib.typ b/lib.typ
index ebdde65..b1efbec 100644
--- a/lib.typ
+++ b/lib.typ
@@ -28,6 +28,7 @@
#import "internal/ratings.typ": rating
#import "internal/dates.typ": _date_format_aliases, _iso_datetime
#import "internal/header.typ": _header, _summary
+#import "internal/qr.typ": _check_qr_code
#import "internal/footer.typ": _auto_page_footer
#import "internal/layout.typ": _sections, _default_preferences
@@ -139,6 +140,7 @@
"labels.months must be an array of 12 strings, got: " + repr(months),
)
}
+ _check_qr_code(preferences.qrCode)
let accent = preferences.accent
let body-size = preferences.bodySize
_accent_state.update(accent)
@@ -233,6 +235,7 @@
link-contact-info: preferences.linkContactInfo,
maps-provider: preferences.mapsProvider,
uppercase-name: preferences.uppercaseName,
+ qr-code: preferences.qrCode,
)
_summary(cv.basics)
diff --git a/tests/header_qr_code.typ b/tests/header_qr_code.typ
new file mode 100644
index 0000000..880fcba
--- /dev/null
+++ b/tests/header_qr_code.typ
@@ -0,0 +1,156 @@
+// `preferences.qrCode` renders a small QR matrix in the header on the
+// side opposite the portrait — restoring one click of digital-PDF
+// affordance when the CV is printed. Documents exercise:
+//
+// 1. `auto` form — encodes `basics.url`, no portrait. QR lands on
+// the side opposite `imagePosition` (default `"right"` → QR on
+// the left).
+// 2. Explicit URL string — distinct from `basics.url`, so the
+// printed CV can point at a landing page tracked separately from
+// the canonical homepage.
+// 3. QR + portrait together — the two ornaments occupy opposite
+// sides with the header text filling the middle column.
+// 4. QR + portrait with `imagePosition: "left"` — confirms the QR
+// follows the photo and ends up on the right.
+// 5. QR themed by a custom `accent` colour — exercises the
+// `fill: accent` path through the QR helper.
+// 6. QR alongside a centred portrait — QR pins the top-left corner
+// next to the photo (top row), with the centred-header text in a
+// separate row below.
+// 7. QR with no portrait + `imagePosition: "center"` — top row is the
+// header text; QR pins its top-left corner.
+// 8. Centred portrait + QR with `imageStackOrder: "below"` — text on
+// top, photo trails below; QR pins the text row's top-left corner.
+// 9. Centred portrait + QR + `headerTextAlign: "left"` — orthogonal
+// case: photo + QR share the top row (photo centred page-wise,
+// QR top-left), text sits flush-left in a row below.
+
+#import "../lib.typ": alta
+
+#alta(
+ (basics: (
+ name: "QR From basics.url",
+ label: "preferences.qrCode: auto",
+ email: "qr@example.com",
+ url: "https://example.com/cv",
+ )),
+ preferences: (qrCode: auto),
+)
+
+#pagebreak()
+
+#alta(
+ (basics: (
+ name: "Explicit QR URL",
+ label: "preferences.qrCode: \"https://...\"",
+ email: "qr@example.com",
+ url: "https://example.com/canonical",
+ )),
+ preferences: (qrCode: "https://example.com/printed-cv"),
+)
+
+#pagebreak()
+
+#alta(
+ (basics: (
+ name: "QR + Portrait (Default)",
+ label: "QR left, photo right",
+ email: "qr@example.com",
+ url: "https://example.com/cv",
+ image: read("../icons/avatar-placeholder.svg", encoding: none),
+ )),
+ preferences: (qrCode: auto),
+)
+
+#pagebreak()
+
+#alta(
+ (basics: (
+ name: "QR + Portrait (Flipped)",
+ label: "Photo left, QR right",
+ email: "qr@example.com",
+ url: "https://example.com/cv",
+ image: read("../icons/avatar-placeholder.svg", encoding: none),
+ )),
+ preferences: (qrCode: auto, imagePosition: "left"),
+)
+
+#pagebreak()
+
+#alta(
+ (basics: (
+ name: "Accent-Themed QR",
+ label: "QR inherits preferences.accent",
+ email: "qr@example.com",
+ url: "https://example.com/cv",
+ )),
+ preferences: (qrCode: auto, accent: rgb("#1976D2")),
+)
+
+#pagebreak()
+
+#alta(
+ (basics: (
+ name: "Centred Portrait + QR",
+ label: "QR pins top-left next to the photo, text below",
+ email: "qr@example.com",
+ url: "https://example.com/cv",
+ image: read("../icons/avatar-placeholder.svg", encoding: none),
+ )),
+ preferences: (
+ qrCode: auto,
+ imagePosition: "center",
+ headerTextAlign: "center",
+ ),
+)
+
+#pagebreak()
+
+#alta(
+ (basics: (
+ name: "Centred Text + QR (No Photo)",
+ label: "QR pins top-left, centred header fills the row",
+ email: "qr@example.com",
+ url: "https://example.com/cv",
+ )),
+ preferences: (
+ qrCode: auto,
+ imagePosition: "center",
+ headerTextAlign: "center",
+ ),
+)
+
+#pagebreak()
+
+#alta(
+ (basics: (
+ name: "Centred Photo Below + QR",
+ label: "imageStackOrder: \"below\" — photo trails the text block",
+ email: "qr@example.com",
+ url: "https://example.com/cv",
+ image: read("../icons/avatar-placeholder.svg", encoding: none),
+ )),
+ preferences: (
+ qrCode: auto,
+ imagePosition: "center",
+ imageStackOrder: "below",
+ headerTextAlign: "center",
+ ),
+)
+
+#pagebreak()
+
+#alta(
+ (basics: (
+ name: "Centred Photo + Left Text + QR",
+ label: "Orthogonal axes — photo centred, header text left-aligned",
+ email: "qr@example.com",
+ url: "https://example.com/cv",
+ image: read("../icons/avatar-placeholder.svg", encoding: none),
+ )),
+ preferences: (
+ qrCode: auto,
+ imagePosition: "center",
+ headerTextAlign: "left",
+ ),
+)