Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ Every theme, font, layout, and behaviour knob lives in `preferences`. Override a
| `uppercaseName` | `true` | When `true` (the default — matching AltaCV's visual ancestor), `basics.name` renders in uppercase. Set to `false` to render the name as supplied. Useful for scripts where uppercase is a different glyph set (Turkish dotless-i, etc.), scripts that have no case at all, or simply when the loud uppercase look isn't wanted. |
| `lastModifiedFooter` | `false` | When `true` and `meta.lastModified` is set, renders a small right-aligned `Last updated: <meta.lastModified>` line in the page footer. The label text is localisable via `labels.lastModified`; the timestamp is rendered as supplied (the full ISO 8601 string flows through verbatim). PDF metadata (date / keywords / description) is enriched from `meta` / `basics` independently of this flag — see [PDF metadata](#pdf-metadata). |
| `dateFormat` | `"long"` | How ISO 8601 date strings (`"2024"`, `"2024-06"`, `"2024-06-15"` — the three shapes the [JSON Resume `iso8601` definition](https://github.com/jsonresume/resume-schema/blob/master/schema.json) accepts) are rendered wherever the template surfaces a date (`startDate`, `endDate`, `awards[].date`, `publications[].releaseDate`, …). Non-ISO strings (e.g. `"Jan 2022"`, `"May 2016 – Jul 2017"`) always pass through verbatim regardless of this setting — back-compat with pre-formatted data. Accepted values: `"long"` (`"Jun 2024"` / `"15 Jun 2024"`, month names sourced 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"`; supported tokens are `year`/`month`/`day` with `padding:` and `repr:long`/`repr:short`/`repr:numerical` modifiers, with `month repr:long`/`repr:short` reading from `labels.months` so they localise), or a closure `parts => str` receiving a `(year, month, day)` dict (`month` and `day` are `none` for year-only / year-month inputs). |
| `anonymous` | `false` | Blind-review mode. When `true`, the rendered header drops `basics.name`, `basics.image`, and the contact bar entirely; `basics.label` (when present) becomes the sole header line. PDF metadata `title` and `author` are swapped for the generic placeholder `"Candidate"`, and the `keywords` / `description` fields are suppressed so the file itself can't unmask the candidate via its document properties. Every other section renders normally — same data dict, same compile command, single toggle. |
| `linkContactInfo` | `true` | Controls whether contact-bar entries are wrapped in deep links (`mailto:`, `tel:`, the configured maps URL for location — see `mapsProvider`, the supplied URL for `basics.url` and for each profile). Accepts a **boolean** (`true` / `false`, applied uniformly to every channel) or a **partial dict** keyed by channel — `"email"`, `"phone"`, `"location"`, `"url"`, `"profiles"` — so you can opt out per channel without touching the data. E.g. `linkContactInfo: (phone: false)` keeps email / location / homepage / profile links but renders the phone as plain text. Omitted channels stay linked; unknown channel keys panic. |
| `mapsProvider` | `maps-providers.google` | URL template for the `basics.location` deep link. The `{q}` placeholder is replaced with the URL-encoded location at render time. Use a built-in template — `maps-providers.{google,apple,bing,duckduckgo,osm}`, all exported from the module — or pass any other URL template string for a provider that isn't built in (no code change required). Pass `none` to suppress the link entirely (icon + plain text still render). Strings missing `{q}` panic; non-string / non-`none` values panic. |
| `columnRatio` | `0.65` | Left-column width as a fraction of the page, in `(0, 1]`. The right column gets the remainder minus a fixed gutter. Use the complement (`1 - r`) to invert the layout, or set to `1` for a [single-column layout](#single-column-layout). |
Expand Down
Binary file added examples/tests/anonymous.pdf
Binary file not shown.
194 changes: 115 additions & 79 deletions lib.typ
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,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 @@ -687,16 +688,21 @@
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 skips the name block entirely — the label
// (when present) becomes the sole header line, so role-relevant
// signal survives while identifying detail is dropped.
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 @@ -709,71 +715,77 @@
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)

// Anonymous mode suppresses the contact bar wholesale — every
// channel (email, phone, location, url, profiles) carries
// identifying signal, so the safe default is to drop them all
// rather than pick winners.
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))
if not anonymous {
let email = basics.at("email", default: none)
if email != none {
entries.push((
channel: "email",
icon: "email",
value: email,
url: "mailto:" + email,
))
}
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.",
)
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.",
)
}
entries.push((
channel: "profiles",
icon: network,
value: profile.at("username", default: profile.at("url", default: "")),
url: profile.url,
))
}
entries.push((
channel: "profiles",
icon: network,
value: profile.at("username", default: profile.at("url", default: "")),
url: profile.url,
))
}

// Each entry is wrapped in `box(...)` so the icon and its
Expand All @@ -800,11 +812,13 @@
// 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).
// a bare `.len()` check). Anonymous mode forces the portrait off
// even when `basics.image` is supplied — validation still runs so
// a malformed value can't slip through unnoticed via the flag.
let has-image = if image-src == none {
false
} else if type(image-src) in (str, bytes) {
image-src.len() > 0
not anonymous and image-src.len() > 0
} else {
panic(
"basics.image must be a string path or bytes, got: " + repr(image-src),
Expand Down Expand Up @@ -1349,6 +1363,12 @@
// "iso" — passthrough of the original string
// closure — (parts) -> str, where parts is (year, month?, day?)
dateFormat: "long",
// Blind-review mode: redacts name, photo, and contact bar from the
// rendered header, and overrides PDF metadata `author` with a
// generic placeholder so the document itself doesn't leak identity.
// The label (e.g. "Senior Software Engineer") still renders, as do
// all other sections — only the identifying header surface is dropped.
anonymous: false,
// Fraction in (0, 1] (validated in alta()). Use the complement
// (`1 - r`) and swap the column-section arrays to invert the layout;
// exactly 1 collapses the grid to a single full-width column.
Expand Down Expand Up @@ -1452,6 +1472,11 @@
"labels.months must be an array of 12 strings, got: " + repr(months),
)
}
if type(preferences.anonymous) != bool {
panic(
"anonymous must be a bool, got: " + repr(preferences.anonymous),
)
}
let accent = preferences.accent
let body-size = preferences.bodySize
_accent_state.update(accent)
Expand All @@ -1464,15 +1489,25 @@
// rejects `none` for `date`, and emitting empty strings for
// `description` / `keywords` would still write a present-but-empty
// entry.
//
// `uppercaseName` is purely visual — PDF metadata stays canonical.
//
// `anonymous` flips the title and author into generic placeholders
// and suppresses the keyword / description fields so the file itself
// can't unmask the candidate via its metadata (a blind reviewer who
// saved the PDF would otherwise see the real name in their reader's
// "document properties" pane, and the description / keywords would
// surface basics.summary and the skill keywords verbatim).
let meta = cv.at("meta", default: (:))
let last-modified-raw = meta.at("lastModified", default: none)
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)
let doc-title = if preferences.anonymous { "Candidate --- CV" } else { cv.basics.name + " --- CV" }
let doc-author = if preferences.anonymous { "Candidate" } else { cv.basics.name }
Comment on lines +1504 to +1505

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Anonymous metadata title value is inconsistent with the stated "Candidate" placeholder.

Line 1245 sets title to "Candidate --- CV" while the feature docs describe "Candidate" as the placeholder. Please align implementation and documentation to one contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib.typ` around lines 1245 - 1246, The anonymous mode document title on line
1245 is inconsistent with the documented placeholder value. The doc-author is
correctly set to "Candidate" as documented, but doc-title uses "Candidate ---
CV" instead of just "Candidate". Update the doc-title assignment to use
"Candidate" as the anonymous title value (without the " --- CV" suffix) to align
with the documented placeholder and maintain consistency with the doc-author
value on line 1246.

let doc-keywords = if preferences.anonymous { () } else { _collect_keywords(cv.at("skills", default: ())) }
let doc-description = if preferences.anonymous { none } else { cv.basics.at("summary", default: none) }
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 @@ -1546,6 +1581,7 @@
link-contact-info: preferences.linkContactInfo,
maps-provider: preferences.mapsProvider,
uppercase-name: preferences.uppercaseName,
anonymous: preferences.anonymous,
)
_summary(cv.basics)

Expand Down
56 changes: 56 additions & 0 deletions tests/anonymous.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// `preferences.anonymous: true` enables blind-review mode: the
// rendered header drops the name, photo, and contact bar, leaving
// only the label as the leading line. PDF metadata `title` and
// `author` are swapped for generic placeholders so the file itself
// can't unmask the candidate via its document properties.
//
// Three documents exercise the relevant shapes:
//
// 1. Anonymous on, fully-populated basics — name / photo /
// contact bar should all be suppressed; label remains.
// 2. Anonymous on, no `label` — header collapses to nothing
// (summary + sections still render).
// 3. Anonymous off (default, asserted explicitly) — every
// identifying field renders normally.

#import "../lib.typ": alta

#let cv = (
basics: (
name: "Jane Doe",
label: "Senior Software Engineer",
summary: [Backend engineer with eight years' experience.],
email: "jane@example.com",
phone: "+353 1 555 0100",
location: "Dublin, Ireland",
url: "https://janedoe.dev",
profiles: (
(network: "GitHub", username: "janedoe", url: "https://github.com/janedoe"),
),
),
work: (
(
name: "Acme Corp",
position: "Senior Software Engineer",
startDate: "Jan 2022",
highlights: ([Led the platform migration.],),
),
),
)
Comment on lines +18 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

This fixture misses two advertised anonymous-mode behaviors.

Case 1 doesn’t provide basics.image, so portrait suppression isn’t exercised. Case 2 claims summary/sections still render, but includes neither. Please add those inputs so the fixture validates what its comments assert.

Suggested fixture adjustments
 `#let` cv = (
   basics: (
     name: "Jane Doe",
     label: "Senior Software Engineer",
+    image: read("../examples/avatar-placeholder.svg", encoding: none),
     summary: [Backend engineer with eight years' experience.],
@@
 `#alta`(
   (basics: (
     name: "Jane Doe",
+    summary: [Anonymous summary should still render.],
     email: "jane@example.com",
     phone: "+353 1 555 0100",
-  )),
+  ),
+  work: (
+    (
+      name: "Acme Corp",
+      position: "Engineer",
+      startDate: "2024",
+      highlights: ([Section rendering still works.],),
+    ),
+  )),
   preferences: (anonymous: true),
 )

Also applies to: 45-52

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/anonymous.typ` around lines 18 - 39, The test fixture in
tests/anonymous.typ is incomplete at two locations and does not validate all
advertised anonymous-mode behaviors. At the first location (lines 18-39) with
the cv object containing basics and work, add a basics.image field to properly
exercise portrait suppression during testing. At the second location (lines
45-52), add both a summary field to the basics section and include a sections
array with data so the fixture can properly validate that summary and sections
content still render correctly in anonymous mode, matching what the test
comments assert.


#alta(cv, preferences: (anonymous: true))

#pagebreak()

#alta(
(basics: (
name: "Jane Doe",
email: "jane@example.com",
phone: "+353 1 555 0100",
)),
preferences: (anonymous: true),
)

#pagebreak()

#alta(cv, preferences: (anonymous: false))