Skip to content
Open
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ The rendered PDF carries metadata in its document properties — what your OS sh

| PDF field | Source | Notes |
|---|---|---|
| Title | `basics.name + " --- CV"` | Always set. |
| Author | `basics.name` | Always set; canonical (ignores `preferences.uppercaseName`). |
| Title | `basics.name + " --- CV"` | Always set. Collapses to `"Candidate"` when `preferences.anonymous` is `true`. |
| Author | `basics.name` | Always set; canonical (ignores `preferences.uppercaseName`). Collapses to `"Candidate"` when `preferences.anonymous` is `true`. |
| Subject (description) | `basics.summary` | Same content rendered in the document header. |
| Keywords | `skills[].keywords` | Flattened across every skill group, de-duplicated, insertion order preserved. |
| Date (CreationDate / ModDate) | `meta.lastModified` | ISO 8601 — `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SSZ`; only the calendar part is used. Falls back to compile time when absent or unparseable. |
Expand Down Expand Up @@ -308,6 +308,7 @@ Every theme, font, layout, and behaviour knob lives in `preferences`. Override a
| `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. |
| `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. |
| `anonymous` | `false` | Blind-review mode. When `true`, the header drops `basics.name`, `basics.image`, and the contact bar entirely — only `basics.label` (e.g. `"Senior Software Engineer"`) survives, since a role title is the only header field that conveys candidate fit without identity. PDF metadata `title` and `author` also collapse to the placeholder `"Candidate"` so file properties can't leak the name. Same data dict, same compile command, single toggle — no need to fork or maintain a stripped copy of `basics`. |
| `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). |
| `dateFormat` | `"long"` | How ISO 8601 dates are rendered wherever the template surfaces a date (`startDate`, `endDate`, `awards[].date`, `publications[].releaseDate`, …). Non-ISO strings pass through verbatim regardless. Accepted: `"long"` (`"Jun 2024"` / `"15 Jun 2024"`, month names from `labels.months`), `"short"` (`"06/2024"` / `"15/06/2024"`), `"iso"` (passthrough), **a bracketed template** in [Typst's `datetime.display()` syntax](https://typst.app/docs/reference/foundations/datetime/#definitions-display) (e.g. `"[day padding:none] [month repr:short] [year]"` → `"15 Jun 2024"`; tokens `year`/`month`/`day` with `padding:` and `repr:long`/`repr:short`/`repr:numerical`, where `month repr:long`/`short` localises via `labels.months`), or a closure `parts => str` receiving `(year, month, day)` (`month` / `day` are `none` for year-only / year-month inputs). |
| `linkContactInfo` | `true` | Whether contact-bar entries are wrapped in deep links (`mailto:`, `tel:`, the configured maps URL for location, the supplied URL for `basics.url` and each profile). Accepts a **boolean** (uniform across channels) or a **partial dict** keyed by `"email"`, `"phone"`, `"location"`, `"url"`, `"profiles"` (omitted channels stay linked). E.g. `linkContactInfo: (phone: false)` linkifies everything except the phone. Unknown channel keys panic. |
Expand Down
Binary file added examples/tests/anonymous.pdf
Binary file not shown.
228 changes: 126 additions & 102 deletions internal/header.typ
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,89 @@

#let _contact_channels = ("email", "phone", "location", "url", "profiles")

// Build the ordered list of contact-bar entries from `basics`. One
// dict per displayed channel: `{channel, icon, value, url}`, where
// `url` may be `none` (no deep link). Pulled out of `_header` so the
// caller can suppress the entire bar (e.g. `preferences.anonymous`)
// with a single conditional rather than wrapping ~90 lines in
// `if not anonymous { ... }` and bloating the diff with pure
// indentation churn.
#let _build_contact_entries(basics, maps-provider) = {
let entries = ()
let email = basics.at("email", default: none)
if email != none {
entries.push((
channel: "email",
icon: "email",
value: email,
url: "mailto:" + email,
))
}
let phone = basics.at("phone", default: none)
if phone != none {
// Strip RFC 3966 visual separators (spaces, parens, hyphens, dots)
// from the dialable URI; the displayed value keeps them intact.
let dialable = phone.replace(regex("[\s()\-.]"), "")
entries.push((
channel: "phone",
icon: "phone",
value: phone,
url: "tel:" + dialable,
))
}
// `_format_location` collapses the JSON Resume dict form
// `{address, postalCode, city, countryCode, region}` to a single
// line, leaves an already-flat string untouched, and returns `none`
// when every relevant field is empty. Both the display value and
// the maps deep link are fed from the same result so they cannot
// drift.
let location = _format_location(basics.at("location", default: none))
if location != none {
let url = if maps-provider == none { none } else {
maps-provider.replace("{q}", _url_encode(location))
}
entries.push((
channel: "location",
icon: "location",
value: location,
url: url,
))
}
let url = basics.at("url", default: none)
if url != none {
entries.push((
channel: "url",
icon: "link",
value: url,
url: url,
))
}
for profile in basics.at("profiles", default: ()) {
let raw = lower(profile.network)
let network = _network_aliases.at(raw, default: raw)
if network not in _profile_networks {
panic(
"Unknown profile network: " + repr(profile.network)
+ ". Supported: " + _profile_networks.join(", ")
+ ". To add another, vendor its SVG into icons/ and register it in _network_icon_sources (internal/icons.typ).",
)
}
entries.push((
channel: "profiles",
icon: network,
// Partial profiles (no `url`) keep working: the display value
// falls back to `url` then "", and the link wrap is gated on
// `entry.url != none` at the call site — using
// `.at("url", default: none)` instead of direct access means
// a profile with only `network` + `username` renders the
// username and skips the link.
value: profile.at("username", default: profile.at("url", default: "")),
url: profile.at("url", default: none),
))
}
entries
}

// Returns a fully-populated per-channel dict so downstream code can
// always `link-config.at(channel)` without missing-key guards.
#let _resolve_link_config(value) = {
Expand Down Expand Up @@ -122,6 +205,7 @@
link-contact-info: true,
maps-provider: maps-providers.google,
uppercase-name: true,
anonymous: false,
) = {
if image-position not in ("left", "right", "center") {
panic("imagePosition must be \"left\", \"right\", or \"center\", got: " + repr(image-position))
Expand All @@ -148,16 +232,24 @@
let accent = _accent_state.get()

let header-text = align(text-align, {
block(
spacing: 0pt,
below: 1.2 * body-size,
text(
2.5 * body-size,
fill: accent,
weight: "bold",
if uppercase-name { upper(basics.name) } else { basics.name },
),
)
// Anonymous mode suppresses name + contact bar wholesale (every
// channel — email, phone, location, URL, profiles — carries
// identifying signal; per-channel opt-out already exists via
// `linkContactInfo`). Only `basics.label` survives, since a
// role title like "Senior Software Engineer" is the only
// header field that conveys candidate fit without identity.
if not anonymous {
block(
spacing: 0pt,
below: 1.2 * body-size,
text(
2.5 * body-size,
fill: accent,
weight: "bold",
if uppercase-name { upper(basics.name) } else { basics.name },
),
)
}

if "label" in basics and basics.label != none {
block(
Expand All @@ -167,98 +259,27 @@
)
}

set text(0.8 * body-size, weight: "bold")
let bar-icon = icon.with(size: 0.9 * body-size, shift: 0.2 * body-size, fill: accent)
if not anonymous {
set text(0.8 * body-size, weight: "bold")
let bar-icon = icon.with(size: 0.9 * body-size, shift: 0.2 * body-size, fill: accent)

let entries = ()
let email = basics.at("email", default: none)
if email != none {
entries.push((
channel: "email",
icon: "email",
value: email,
url: "mailto:" + email,
))
}
let phone = basics.at("phone", default: none)
if phone != none {
// Strip RFC 3966 visual separators (spaces, parens, hyphens, dots)
// from the dialable URI; the displayed value keeps them intact.
let dialable = phone.replace(regex("[\s()\-.]"), "")
entries.push((
channel: "phone",
icon: "phone",
value: phone,
url: "tel:" + dialable,
))
// Each entry is wrapped in `box(...)` so the icon and its
// display text stay together when the contact bar wraps —
// line breaks fall on the inter-entry `h(...)` joins, never
// between an icon and the text it labels.
_build_contact_entries(basics, maps-provider)
.map(entry => box({
bar-icon(entry.icon)
let value = [#entry.value]
if link-config.at(entry.channel) and entry.url != none {
link(entry.url, value)
} else { value }
}))
.join(h(1.2 * body-size))
// Inherits par.spacing, so the gap stays in sync with the rest
// of the document even when bodySize is tweaked.
parbreak()
}
// `_format_location` collapses the JSON Resume dict form
// `{address, postalCode, city, countryCode, region}` to a
// single line, leaves an already-flat string untouched, and
// returns `none` when every relevant field is empty. Both the
// display value and the maps deep link are fed from the same
// result so they cannot drift.
let location = _format_location(basics.at("location", default: none))
if location != none {
let url = if maps-provider == none { none } else {
maps-provider.replace("{q}", _url_encode(location))
}
entries.push((
channel: "location",
icon: "location",
value: location,
url: url,
))
}
let url = basics.at("url", default: none)
if url != none {
entries.push((
channel: "url",
icon: "link",
value: url,
url: url,
))
}
for profile in basics.at("profiles", default: ()) {
let raw = lower(profile.network)
let network = _network_aliases.at(raw, default: raw)
if network not in _profile_networks {
panic(
"Unknown profile network: " + repr(profile.network)
+ ". Supported: " + _profile_networks.join(", ")
+ ". To add another, vendor its SVG into icons/ and register it in _network_icon_sources (internal/icons.typ).",
)
}
entries.push((
channel: "profiles",
icon: network,
// Partial profiles (no `url`) keep working: the display
// value falls back to `url` then "", and the link wrap
// is gated on `entry.url != none` below — using
// `.at("url", default: none)` instead of direct access
// means a profile with only `network` + `username`
// renders the username and skips the link.
value: profile.at("username", default: profile.at("url", default: "")),
url: profile.at("url", default: none),
))
}

// Each entry is wrapped in `box(...)` so the icon and its
// display text stay together when the contact bar wraps —
// line breaks fall on the inter-entry `h(...)` joins, never
// between an icon and the text it labels.
entries
.map(entry => box({
bar-icon(entry.icon)
let value = [#entry.value]
if link-config.at(entry.channel) and entry.url != none {
link(entry.url, value)
} else { value }
}))
.join(h(1.2 * body-size))
// Inherits par.spacing, so the gap stays in sync with the rest
// of the document even when bodySize is tweaked.
parbreak()
})

let image-src = basics.at("image", default: none)
Expand All @@ -267,8 +288,10 @@
// Anything else panics with a clear message instead of falling
// through to a cryptic `image()` failure or — worse — silently
// dropping the photo (which is what an empty array would do under
// a bare `.len()` check).
let has-image = if image-src == none {
// a bare `.len()` check). Validation runs even under `anonymous`
// so a malformed `basics.image` still surfaces — the anonymity
// toggle only suppresses rendering, never validation.
let image-present = if image-src == none {
false
} else if type(image-src) in (str, bytes) {
image-src.len() > 0
Expand All @@ -277,6 +300,7 @@
"basics.image must be a string path or bytes, got: " + repr(image-src),
)
}
let has-image = image-present and not anonymous
if has-image {
// Swapping the column order moves the photo to the opposite
// side without changing the alignment of the text within its
Expand Down
7 changes: 7 additions & 0 deletions internal/layout.typ
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@
// PDF metadata (title / author) stays as-supplied regardless of
// this flag — see the comment above `set document(...)`.
uppercaseName: true,
// Blind-review mode. When `true`, the header drops `basics.name`,
// `basics.image`, and the contact bar entirely — only `basics.label`
// remains (or nothing, if absent). PDF metadata `title` and `author`
// also collapse to the placeholder `"Candidate"` so file properties
// can't leak identity. Same data dict, same compile command, single
// toggle — no need to maintain a stripped-down copy of `basics`.
anonymous: false,
// When true and `cv.meta.lastModified` is set, render a small
// "Last updated: <value>" line in the page footer. PDF metadata
// (date / keywords / description) is populated from `meta` and
Expand Down
14 changes: 11 additions & 3 deletions lib.typ
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
}
_check_bool("uppercaseName", preferences.uppercaseName)
_check_bool("lastModifiedFooter", preferences.lastModifiedFooter)
_check_bool("anonymous", preferences.anonymous)
let max-rating = preferences.maxRating
if type(max-rating) != int or max-rating < 1 {
panic("maxRating must be a positive integer, got: " + repr(max-rating))
Expand Down Expand Up @@ -155,10 +156,16 @@
let doc-date = _iso_datetime(last-modified-raw)
let doc-keywords = _collect_keywords(cv.at("skills", default: ()))
let doc-description = cv.basics.at("summary", default: none)
// `uppercaseName` is purely visual — PDF metadata stays canonical.
// `anonymous` collapses identifying metadata to a generic placeholder
// so the file's document properties can't leak name through e.g. an
// OS file-info panel or a search index. The placeholder mirrors what
// the rendered header shows for an anonymised CV.
let doc-title = if preferences.anonymous { "Candidate" } else { cv.basics.name + " --- CV" }
let doc-author = if preferences.anonymous { "Candidate" } else { cv.basics.name }
set document(
// `uppercaseName` is purely visual — PDF metadata stays canonical.
title: cv.basics.name + " --- CV",
author: cv.basics.name,
title: doc-title,
author: doc-author,
..(if doc-keywords.len() > 0 { (keywords: doc-keywords) } else { (:) }),
..(if _present(doc-description) { (description: doc-description) } else { (:) }),
..(if doc-date != none { (date: doc-date) } else { (:) }),
Expand Down Expand Up @@ -232,6 +239,7 @@
link-contact-info: preferences.linkContactInfo,
maps-provider: preferences.mapsProvider,
uppercase-name: preferences.uppercaseName,
anonymous: preferences.anonymous,
)
_summary(cv.basics)

Expand Down
Loading