Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<!-- Relative path so the GIF tracks the codebase: GitHub renders the README from main, Universe renders it from the package version's snapshot — each context sees the matching frame set. examples/ ships in the typst/packages submission but is `exclude`d from the compiler bundle (see typst.toml), so the file lives next to README on Universe without bloating the import payload. -->
<p align="center">
<img alt="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" src="examples/preview.gif">
<img alt="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" src="examples/preview.gif">
</p>

## Features
Expand All @@ -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

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<labels.lastModified>: <meta.lastModified>` 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. |
Expand Down
18 changes: 12 additions & 6 deletions examples/preview-frames.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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: (
Expand Down
Binary file modified examples/preview.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified examples/tests/centred_header_image.pdf
Binary file not shown.
Binary file added examples/tests/header_qr_code.pdf
Binary file not shown.
103 changes: 67 additions & 36 deletions internal/header.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions internal/layout.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions internal/qr.typ
Original file line number Diff line number Diff line change
@@ -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),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
}
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),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
3 changes: 3 additions & 0 deletions lib.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -233,6 +235,7 @@
link-contact-info: preferences.linkContactInfo,
maps-provider: preferences.mapsProvider,
uppercase-name: preferences.uppercaseName,
qr-code: preferences.qrCode,
)
_summary(cv.basics)

Expand Down
Loading