diff --git a/README.md b/README.md index c0cc9c8..09346dc 100644 --- a/README.md +++ b/README.md @@ -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: ` 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). | diff --git a/examples/tests/anonymous.pdf b/examples/tests/anonymous.pdf new file mode 100644 index 0000000..6d85fc3 Binary files /dev/null and b/examples/tests/anonymous.pdf differ diff --git a/lib.typ b/lib.typ index 0e7df5c..4cbd175 100644 --- a/lib.typ +++ b/lib.typ @@ -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)) @@ -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( @@ -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 @@ -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), @@ -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. @@ -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) @@ -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 } + 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 { (:) }), @@ -1546,6 +1581,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..0b54ba3 --- /dev/null +++ b/tests/anonymous.typ @@ -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.],), + ), + ), +) + +#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))