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 @@

- Animated preview of the altacv template — seven frames covering each accent palette plus a centred-portrait variant, each combining several customisations (column arrangement, image position, header alignment, date format, single-column layout) to show what's tunable + Animated preview of the altacv template — seven frames covering each accent palette plus a centred-portrait variant, each combining several customisations (column arrangement, image position, header alignment, date format, single-column layout, header QR code) to show what's tunable

## 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", + ), +)