From 8dec06f78f8f0a913633dfe8c75efad7f6c8d0b5 Mon Sep 17 00:00:00 2001 From: Shane Murphy Date: Sun, 14 Jun 2026 21:03:00 +0200 Subject: [PATCH] feat: add preferences.anonymous for blind-review mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #55. New boolean preference (default false). When true: - Header drops basics.name, basics.image, and the contact bar entirely. Only basics.label (e.g. "Senior Software Engineer") survives — a role title is the only header field that conveys candidate fit without identity. Per-channel opt-out for contact info already exists via linkContactInfo, so anonymous mode suppresses the bar wholesale rather than re-implementing per-channel gating. - PDF metadata title and author collapse to the placeholder "Candidate" so file properties can't leak the name through an OS file-info panel or a search index. Description (basics.summary) and keywords (skills[].keywords) stay populated — neither is identity-bearing in the same way and the issue spec only called out title/author. - Validation still runs in anonymous mode: a malformed basics.image or linkContactInfo dict still panics, so flipping the toggle later doesn't surface latent bugs. The contact-bar entry build (~75 lines) was extracted from _header into a module-level _build_contact_entries helper. Without that extraction the anonymity guard would be a pure-indentation wrap of the existing block, drowning the actual logic in diff noise. Fixture (tests/anonymous.typ, 3 pages): 1. anonymous off, full basics — regression guard against perturbations to the canonical render. 2. anonymous on, no label — header collapses cleanly (no orphan spacing). 3. anonymous on, full basics — canonical blind-review render. Rendered last so the document's set document(...) wins and pdfinfo on the tracked PDF reports title/author = "Candidate". --- README.md | 5 +- examples/tests/anonymous.pdf | Bin 0 -> 46491 bytes internal/header.typ | 228 +++++++++++++++++++---------------- internal/layout.typ | 7 ++ lib.typ | 14 ++- tests/anonymous.typ | 111 +++++++++++++++++ 6 files changed, 258 insertions(+), 107 deletions(-) create mode 100644 examples/tests/anonymous.pdf create mode 100644 tests/anonymous.typ diff --git a/README.md b/README.md index 37eba6d..cc35eb9 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 `: ` 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. | diff --git a/examples/tests/anonymous.pdf b/examples/tests/anonymous.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6d52770c7a8958e6c96128f5a253e466f99b14a7 GIT binary patch literal 46491 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≥# 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 aQ22M?+km4p>VA+L2IuD|U}lz5l>R?WSJ;RE literal 0 HcmV?d00001 diff --git a/internal/header.typ b/internal/header.typ index bcd5d75..d1b24f7 100644 --- a/internal/header.typ +++ b/internal/header.typ @@ -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) = { @@ -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)) @@ -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( @@ -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) @@ -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 @@ -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 diff --git a/internal/layout.typ b/internal/layout.typ index dde8b27..a61e71a 100644 --- a/internal/layout.typ +++ b/internal/layout.typ @@ -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: " line in the page footer. PDF metadata // (date / keywords / description) is populated from `meta` and diff --git a/lib.typ b/lib.typ index 23d178e..065c2de 100644 --- a/lib.typ +++ b/lib.typ @@ -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)) @@ -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 { (:) }), @@ -232,6 +239,7 @@ link-contact-info: preferences.linkContactInfo, maps-provider: preferences.mapsProvider, uppercase-name: preferences.uppercaseName, + anonymous: preferences.anonymous, ) _summary(cv.basics) diff --git a/tests/anonymous.typ b/tests/anonymous.typ new file mode 100644 index 0000000..3e25f04 --- /dev/null +++ b/tests/anonymous.typ @@ -0,0 +1,111 @@ +// `preferences.anonymous` — blind-review mode. The same `basics` dict +// renders three different headers depending on the toggle: +// 1. anonymous: false (default) — regression guard. Full header +// renders unchanged: name + photo + complete contact bar. +// 2. anonymous: true with no `basics.label`. Header collapses +// cleanly — no orphan vertical space where the label would have +// sat, no leftover spacing between the (suppressed) name and the +// section grid below. +// 3. anonymous: true with a fully-populated basics (name, label, +// photo, every contact channel, profiles). Header collapses to +// just the label; portrait + contact bar disappear. +// +// Page 3 (anonymous + full basics) is rendered last so the document's +// PDF metadata reflects the anonymous path — each `#alta(...)` calls +// `set document(...)` and the last call wins. `pdfinfo +// examples/tests/anonymous.pdf` should report `Title: Candidate` and +// `Author: Candidate`, confirming identity doesn't leak through file +// properties either. + +#import "../lib.typ": alta + +// Defaults — anonymous is false. Full header. +#alta(( + basics: ( + name: "Jane Doe", + label: "Senior Software Engineer", + email: "jane@example.com", + phone: "+353 1 555 0100", + location: "Dublin, Ireland", + url: "https://janedoe.example.com", + image: read("../icons/avatar-placeholder.svg", encoding: none), + profiles: ( + (network: "GitHub", username: "janedoe", url: "https://github.com/janedoe"), + (network: "LinkedIn", username: "janedoe", url: "https://linkedin.com/in/janedoe"), + ), + summary: [Distributed systems engineer with a decade of work on payments infrastructure.], + ), + work: ( + ( + name: "Acme Corp", + position: "Staff Engineer", + startDate: "2022", + highlights: ([Owned the payments rewrite end to end.],), + ), + ), + skills: ( + (name: "Backend", keywords: ("Scala", "Rust", "PostgreSQL")), + ), +)) + +#pagebreak() + +// Anonymous, no label — header collapses to nothing (no name, no +// label, no contact bar). The summary + sections still render below +// to confirm spacing tokens don't leave a phantom strip behind. +#alta( + ( + basics: ( + name: "Jane Doe", + email: "jane@example.com", + summary: [Header above is empty — no name, no label, no contact bar.], + ), + work: ( + ( + name: "Acme Corp", + position: "Staff Engineer", + startDate: "2022", + highlights: ([Owned the payments rewrite end to end.],), + ), + ), + ), + preferences: (anonymous: true), +) + +#pagebreak() + +// Anonymous, full basics. Header collapses to just the label; the +// photo and every contact channel are suppressed. This is the +// canonical blind-review render — and being the last `#alta(...)` +// call, its `set document(...)` wins, so PDF metadata reads +// "Candidate" / "Candidate". +#alta( + ( + basics: ( + name: "Jane Doe", + label: "Senior Software Engineer", + email: "jane@example.com", + phone: "+353 1 555 0100", + location: "Dublin, Ireland", + url: "https://janedoe.example.com", + image: read("../icons/avatar-placeholder.svg", encoding: none), + profiles: ( + (network: "GitHub", username: "janedoe", url: "https://github.com/janedoe"), + (network: "LinkedIn", username: "janedoe", url: "https://linkedin.com/in/janedoe"), + ), + summary: [Distributed systems engineer with a decade of work on payments infrastructure.], + ), + work: ( + ( + name: "Acme Corp", + position: "Staff Engineer", + startDate: "2022", + highlights: ([Owned the payments rewrite end to end.],), + ), + ), + skills: ( + (name: "Backend", keywords: ("Scala", "Rust", "PostgreSQL")), + ), + ), + preferences: (anonymous: true), +)