diff --git a/packages/preview/modernpro-cv/2.1.2/CHANGELOG.md b/packages/preview/modernpro-cv/2.1.2/CHANGELOG.md new file mode 100644 index 0000000000..8bed830cde --- /dev/null +++ b/packages/preview/modernpro-cv/2.1.2/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +## [2.1.2] - 2026-09-29 + +### Fixed + +- Center contact icons by their visible glyph bounds within the first text line, + preventing Font Awesome metrics and legacy baseline overrides from changing + alignment or row spacing. +- Let the first-page header grow beyond its minimum height so additional or + wrapped contacts do not overlap, including inline and photo layouts. +- Wrap long contact labels without changing link destinations, keep icons at + the first line, and bound photo-rail width to preserve space for the name. +- Top-align tall headers while preserving the alignment of compact headers. + +### Documentation + +- Clarified minimum header height, contact wrapping, photo layouts, and matching + headers across documents. Added configuration recipes and local regression + commands for the header fixes. + +## [2.1.1] - 2026-09-06 + +### Fixed + +- Corrected body leading and list spacing in every density preset so wrapped + lines and adjacent list items keep a consistent visual rhythm. +- Made entry and section separators weak so their spacing collapses to one + rhythm token, keeping every section transition consistent. + +### Changed + +- Opened the default and relaxed line and section rhythm for more readable CVs. +- Replaced example, starter, legacy, bibliography, and preview content with + explicitly fictional Exampleland data and reserved `.invalid` addresses. + +## [2.1.0] - 2026-08-27 + +### Added + +- Added optional `profile.photo` content for single- and double-column CVs, with + configurable `theme.photo-width` and `theme.photo-height` dimensions. +- Added documented flat, material, glass, harmonious, and classic photo-header + examples while preserving the text-only, ATS-first default. + +### Fixed + +- Fixed `_first-filled` so explicit configuration values take precedence over + defaults. +- Fixed legacy optional-field checks, contacts without links, contextual page + counters, and caller-resolved bibliography paths. + +### Changed + +- Extended release validation to compile the legacy CV and cover-letter + examples alongside the current templates. + +## [2.0.0] - 2026-07-30 + +Visual redesign. The configuration API stays backward compatible: every 1.x +parameter, dictionary key, and function alias still resolves. Documents rebuilt +on 2.0.0 will look different, which is the point of the release. + +### Changed + +- Rebuilt the visual system around a single serif family in two weights, a restrained 8.4-18pt size ladder, and four colours. Body text now defaults to 10pt for more comfortable academic reading. 1.x mixed PT Sans and PT Serif across titles, dates, institutions, and descriptions, which reads as noise at CV sizes. +- 视觉体系重建为单一衬线字族、两种字重、8.4-18pt 的克制字号阶与四个颜色;正文默认提升到 10pt,以改善学术材料的连续阅读。1.x 在标题、日期、机构、描述之间混用 PT Sans 与 PT Serif,在 CV 的小字号下形成视觉噪音。 +- Fixed the header: the name and role lines physically overlapped, because Typst measures a line box down to the baseline and leaves descenders outside it. The identity block now extends its bottom edge. +- 修复页眉重叠:Typst 的行盒下沿默认止于基线,降部落在盒外,导致姓名与职称物理重叠。身份区块现在扩展下沿边界。 +- Replaced the four-corner entry grid with a left content block and a right date rail. Location joins the institution line instead of occupying the bottom-right corner, so the right edge stays a single clean column. +- 将四角网格改为「左侧内容块 + 右侧日期轨」。地点并入机构行,不再占据右下角,右缘保持为单一整齐的列。 +- Corrected the hierarchy inversion where a description rendered larger and darker than the institution line above it. Institution and description now share one size; weight and restrained colour establish the hierarchy without another italic layer. +- 修正层级倒置:此前描述文字比其上方的机构行更大更深。现在机构与描述同字号,并通过字重与克制的颜色建立层级,不再增加一层斜体。 +- A smaller date beside a larger title now shares its baseline instead of aligning on cap-height, and the alignment survives a title that wraps. +- 标题旁的小号日期现在与标题共享基线,而非按字母高度顶对齐;标题折行时对齐依然成立。 +- Page margins are 2.2cm left and right with a fixed 2cm top margin, matching modernpro-coverletter. Continuation settings no longer move the first-page masthead. +- 左右页边距统一为 2.2cm、顶部固定为 2cm,与 modernpro-coverletter 一致;开启续页页眉不再改变第一页的位置。 +- Institution and degree metadata now use regular text, while locations move to the muted colour. This removes a full italic layer from dense pages. +- 机构与学位信息改用常规体,地点使用弱化颜色,减少密集页面中的整层斜体纹理。 +- Single-column CVs enable a compact continuation header by default. Later pages show the candidate name, document label, and page count without duplicating the name in the footer. +- 单栏 CV 默认启用紧凑续页页眉;后续页面显示姓名、文档类型和页码,不再在页脚重复姓名。 +- Reference entries put the name and role on separate lines so every block has the same line count and the columns stay aligned. +- 推荐人条目将姓名与职务分行,使每个区块行数一致、双列始终对齐。 +- Opened the default entry rhythm further: section headings, entry titles, institution lines, descriptions, and following entries now have clearer visual separation while retaining the compact preset for one-page summaries. +- 进一步放松默认条目节奏:章节标题、条目标题、机构行、描述与下一条目之间具有更清晰的视觉分隔,同时保留适合一页式摘要的紧凑预设。 +- Replaced public sample identities and bibliography records with explicitly fictional data, reserved domains, and placeholder identifiers. +- Expanded the README into a complete workflow covering layout selection, first edits, common recipes, accessibility, ATS guidance, and troubleshooting. +- Modernized the release workflow to validate the package version, compile both examples, build core and legacy archives, and publish with the repository-scoped GitHub token. + +### Added + +- A single `cv` entry point takes `profile`, `preset`, `accent`, and `columns`. A typical document now configures one key. `cv-single` and `cv-double` remain available. +- 新增统一入口 `cv`,接受 `profile`、`preset`、`accent`、`columns`。常规文档只需配置一个键。`cv-single` 与 `cv-double` 继续可用。 +- `preset: "compact" | "default" | "relaxed"` replaces `layout: (density: ...)`, which still works. +- `preset: "compact" | "default" | "relaxed"` 取代 `layout: (density: ...)`,后者仍可使用。 +- Each starter keeps its editable profile dictionary inline, so every generated CV is self-contained and can be managed as one file. +- 每个起始模板都将可编辑的个人资料字典直接放在正文文件中,使生成的 CV 自包含并可用单文件管理。 +- Added optional contact icons with a fixed alignment column while keeping the core template independent of icon libraries and preserving text-only defaults. +- 增加可选联系方式图标与固定对齐列;核心模板不依赖图标库,并继续保留纯文字默认方案。 +- Introduced `section-block` and `render-sections` helpers for composing section content once and reordering it declaratively in templates and examples. +- 增加了 `section-block` 和 `render-sections` 助手,便于在编写内容中方便排序和组合。 diff --git a/packages/preview/modernpro-cv/2.1.2/LICENSE b/packages/preview/modernpro-cv/2.1.2/LICENSE new file mode 100644 index 0000000000..54f897e23c --- /dev/null +++ b/packages/preview/modernpro-cv/2.1.2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Academic Template Collective + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/modernpro-cv/2.1.2/README.md b/packages/preview/modernpro-cv/2.1.2/README.md new file mode 100644 index 0000000000..4b22025952 --- /dev/null +++ b/packages/preview/modernpro-cv/2.1.2/README.md @@ -0,0 +1,625 @@ +# modernpro-cv + +An academic-first Typst CV template: one serif family, one accent colour, a +restrained type scale, and simple content helpers. + +- `cv` is the single entry point. It is the best default for long documents, + printing, accessibility, and applicant tracking systems (ATS). +- `cv(columns: 2, ...)` is a compact one-page variant for human readers. + Multi-column PDF extraction can interleave content, so do not use it when ATS + parsing matters. + +The examples use fictional people, institutions, publications, and awards. + +For a matching letter and statement template, see +[modernpro-coverletter](https://typst.app/universe/package/modernpro-coverletter). The two +packages share a design system and take the same `profile` shape, so a CV, a +cover letter, and a research statement read as one application. Each starter +keeps that dictionary inline so the document remains self-contained. An +optional `photo` is a CV-only presentation detail; letter and statement +templates ignore it. + +## Preview + +### Canonical academic CV + +[![Single-column academic CV](screenshots/cv-single.png)](screenshots/cv-single.png) + +### Compact two-column CV + +[![Two-column compact CV](screenshots/cv-double.png)](screenshots/cv-double.png) + +## Choose a layout + +| Use case | Start from | Why | +| --- | --- | --- | +| Academic applications, grants, promotion, or ATS upload | `cv-single.typ` | One reading order, comfortable multi-page flow, and compact continuation headers | +| Conference bios, networking, or a one-page human-readable résumé | `cv-double.typ` | More information above the fold in a compact visual summary | + +When in doubt, use the single-column version. Keep the two-column version to one +page and do not rely on it when automated PDF extraction matters. + +## Quick start + +Create and compile a project with the Typst CLI: + +```bash +typst init @preview/modernpro-cv:2.1.2 +cd modernpro-cv +typst compile cv-single.typ +``` + +The starter contains: + +```plain +modernpro-cv/ +├── bib.bib +├── cv-double.typ +└── cv-single.typ +``` + +During editing, rebuild automatically with: + +```bash +typst watch cv-single.typ +``` + +You can also select `modernpro-cv` from the template gallery in the Typst web +app. + +### First edit checklist + +1. Replace the placeholder identity at the top of `cv-single.typ`. +2. Open `cv-single.typ` and delete any sections that do not apply. +3. Replace every fictional appointment, degree, publication, date, and claim. +4. Compile the document and check page breaks, contact wrapping, and the final + line of each page. +5. Use `cv-double.typ` only if you also need a compact one-page version. + +The repository examples use explicit Placeholder entities from Exampleland and +reserved `.invalid` domains. Put real information only in your own downstream +document, not in a public template fork. + +## Minimal academic CV + +`profile` is the only argument a typical CV needs. Define it at the top of the +same file so identity, content, and presentation stay together: + +```typst +#import "@preview/modernpro-cv:2.1.2": * +#let profile = ( + name: [Dr. Nova Placeholder], + role: [Lecturer in Speculative Systems], + address: [Sample City, Exampleland], + contacts: ( + (text: [nova\@candidate.invalid], link: "mailto:nova@candidate.invalid"), + (text: [nova.candidate.invalid], link: "https://nova.candidate.invalid"), + (text: [Fictional ID~0000-0000], link: "https://registry.example.invalid/0000-0000"), + ), +) + +#show: cv.with(profile: profile) + +#section("Research Profile") +#summary[ + Researcher studying imaginary institutions and simulated decision engines. +] +#section-gap + +#section("Academic Appointments") +#experience( + title: "Lecturer in Speculative Systems", + institution: [Exampleland University], + location: "Sample City, Exampleland", + date: "20XY-present", +) +#section-gap + +#section("Education") +#education( + institution: [Placeholder Institute], + major: [PhD in Speculative Systems], + date: "20XS-20XW", + location: "Demo Harbour, Exampleland", +) +#section-gap + +#section("Selected Publications") +#entry( + title: [Governing imaginary models through simulated review], + right: "20YY", + meta: [N. Placeholder and A. Example, Journal of Imaginary Systems 8(2)], +) +``` + +Sections render in source order. Move a section to move it in the CV; delete it +to hide it. + +## Recommended content workflow + +Keep presentation decisions in the `#show: cv.with(...)` call and keep +application content below it: + +- Put identity and contacts immediately above the `#show` rule in the same file. +- Use one `#section(...)` for each academic category. +- Use semantic helpers such as `experience`, `education`, and `entry` instead + of manually aligning dates. +- Write dates as short strings such as `"2023-present"` so the right rail stays + compact. +- Add `#section-gap` once after a complete section; entry helpers already manage + spacing between records. + +For a long CV, begin with the default preset and let it flow to another page. +Use the compact preset only after removing redundant content. + +## The four settings + +Everything beyond `profile` is optional: + +| Setting | Values | Purpose | +| --- | --- | --- | +| `profile` | `name`, optional `role`, `address`, `contacts`, `photo` | Who you are | +| `preset` | `"compact"`, `"default"`, `"relaxed"` | The whole vertical rhythm | +| `accent` | any colour | The one colour in the document | +| `columns` | `1` or `2` | Single-column CV, or the compact variant | + +```typst +#show: cv.with( + profile: profile, + preset: "compact", + accent: rgb("#1e3a5f"), +) +``` + +- `"default"` is the academic baseline: clear section hierarchy, comfortable + entry spacing, a compact identity header. +- `"compact"` is for unusually long CVs or strict page limits. +- `"relaxed"` suits shorter dossiers and presentation copies. + +A preset coordinates header rows, section-to-content spacing, entry rows, +descriptions, body leading, and list spacing at once. Choose a preset rather +than tuning gaps individually. + +## Design system + +The same tokens drive modernpro-cv and modernpro-coverletter. + +| | | +| --- | --- | +| Family | PT Serif, falling back to Libertinus Serif — one family, two weights | +| Sizes | 8.4-8.8pt dates and footer · 9.8-10pt body and metadata · 10.5pt entry titles · 18pt name | +| Colours | `#1f2933` ink · `#667085` muted · `#1e3a5f` accent · `#dde3ea` rules | +| Margins | 2.2cm left and right · fixed 2cm top, matching the letter template | + +Entry layout is a left content block and a right date rail: the title and its +institution sit on the left, the date alone occupies the right, and location +joins the institution line. The right edge therefore stays a single clean +column, and there is no zig-zag reading path. + +Hierarchy comes from weight, case, italics, colour, and position — not from +extra type sizes. Section headings are body-size, uppercase, bold, tracked, and +followed by a rule; that is enough separation without introducing another step +in the ladder. + +## Contacts + +The adaptive header improvements described below are available in version 2.1.2. + +Contacts are plain text by default, keeping the header quiet and PDF extraction +clean. A contact can be linked or unlinked: + +```typst +contacts: ( + (text: [nova\@candidate.invalid], link: "mailto:nova@candidate.invalid"), + (text: [nova.candidate.invalid], link: "https://nova.candidate.invalid"), + [Sample City, Exampleland], +) +``` + +Escape `@` as `\@` inside Typst content (`[...]`). Quoted strings such as +`"nova@candidate.invalid"` do not need that escape. Two or three concise contacts +keep the header short, but five is not a limit: add the items you need in the +order you want them to appear. + +### Sizing and wrapping + +`layout.header-height` is a **minimum**, not a fixed height or a limit. It +defaults to 17 mm without a photo, or `theme.photo-height` (20 mm by default) +with a photo. Additional contacts and wrapped lines expand the header; the +divider and document body move down with it. Font sizes and row spacing stay +at their configured values. + +Headers that fit retain their compact alignment. When content exceeds the +minimum, side-by-side identity, contact, and photo blocks align at the top. +Long email addresses and URLs can break at punctuation; an individual segment +wider than its column can break between characters. The text is not truncated, +no hyphens or invisible characters are added to copied labels, and link +destinations stay unchanged. + +### Contact layouts + +| Profile | `layout.contact-layout` | First-page layout | +| --- | --- | --- | +| Without a photo | `"stacked"` (default) | Identity on the left; one contact per row on the right | +| Without a photo | `"inline"` | Identity on the left; contacts flowing across lines on the right | +| With a photo | `"stacked"` (default) or `"inline"` | Identity and inline contacts on the left; photo on the right | +| With a photo | `"rail"` | Identity on the left; stacked contacts beside the photo on the right | + +Inline items stay together when they fit the available width; a longer item +wraps within that width. The photo rail limits contact width to preserve space +for the name. These layouts apply to both single- and two-column CVs. + +### Optional icons + +For a human-facing version, add an optional `icon` field. The template accepts +any Typst content and keeps the icon in a small fixed column, so the labels stay +aligned and remain fully searchable: + +```typst +#import "@preview/fontawesome:0.6.2": fa-icon + +contacts: ( + ( + icon: fa-icon("envelope", solid: true), + text: [nova\@candidate.invalid], + link: "mailto:nova@candidate.invalid", + ), + ( + icon: fa-icon("id-badge", solid: true), + text: [Fictional ID~0000-0000], + link: "https://registry.example.invalid/0000-0000", + ), +) +``` + +The core template does not import an icon library. The example above uses +[Font Awesome for Typst](https://typst.app/universe/package/fontawesome/) and +requires the corresponding Font Awesome desktop fonts. Omit `icon` for the +lowest-friction, ATS-first setup. Icons should supplement familiar labels, not +replace them. + +Icons are centered by their visible glyph bounds within the label's first line, +including when the label wraps. Font Awesome needs no `top-edge` adjustment; +existing icons with `top-edge: "baseline"` also remain supported. +Contacts without icons still line up with the other labels in a stacked list. + +## Optional profile photo + +Pass a ready-to-render image as `profile.photo`. The default 16 x 20 mm portrait +fits a 4:5 frame with square corners: + +[![Academic CV with an optional profile photo](screenshots/cv-photo-harmonious.png)](screenshots/cv-photo-harmonious.png) + +```typst +#let profile = ( + name: [Dr. Nova Placeholder], + role: [Lecturer in Speculative Systems], + address: [Sample City, Exampleland], + photo: image( + "portrait.jpg", + width: 16mm, + height: 20mm, + fit: "cover", + alt: "Portrait of the fictional candidate", + ), + contacts: ( + (text: [nova\@candidate.invalid], link: "mailto:nova@candidate.invalid"), + ), +) +``` + +The image is resolved in the CV document, then placed at the right edge of the +first-page header. This masthead is identical in single- and two-column CVs; +only the body grid changes. When a photo is present, identity and inline +contacts form one left-hand group while the portrait anchors the right edge. +The minimum header height defaults to the photo height, and the header can grow +further to fit its content. Use the [photo rail recipe](#place-contacts-beside-a-photo) +to put stacked contacts beside the portrait instead. +The field accepts any Typst content, including a simple initials placeholder. +It is not repeated in continuation headers. Omit `photo` for ATS submissions +or applications where photographs are discouraged; cover letters and +statements keep the shared typography and rule without repeating the image. + +## Academic content helpers + +Use one small helper for each kind of content: + +| Helper | Purpose | +| --- | --- | +| `#section("Education")` | Start a section with the shared heading style | +| `#section-gap` | Add separation after a complete section | +| `#summary[...]` | Research profile or short overview | +| `#experience(title:, institution:, location:, date:, details:)` | Appointment, research role, teaching role, or service position | +| `#education(institution:, major:, date:, location:, description:)` | Degree or qualification | +| `#entry(title:, right:, meta:, location:, details:)` | Publication, grant, talk, project, or other entry | +| `#detail-line(title:, content:)` | Compact methods, tools, languages, or memberships line | +| `#award(award:, institution:, date:)` | Compact award entry | +| `#reference-list(references:)` | Two-column reference list | + +In every entry helper, `date` (or `right`) goes to the date rail, and +`institution`/`meta` and `location` are joined on the line below the title. + +`experience`, `education`, and `entry` add their own spacing after each entry. +Do not insert `#item-gap` between these helpers. `#item-gap` remains available +for custom content that is not produced by an entry helper. + +A common academic sequence is: + +1. Research Profile +2. Academic Appointments +3. Education +4. Publications +5. Research Funding +6. Teaching and Supervision +7. Service +8. Awards +9. References + +Use only the sections that strengthen the document. + +## Compact two-column variant + +`columns: 2` uses the same fonts, colour tokens, and content helpers as the +canonical CV. Only the page structure changes. + +```typst +#import "@preview/modernpro-cv:2.1.2": * +#let profile = ( + name: [Dr. Nova Placeholder], + role: [Lecturer in Speculative Systems], + address: [Sample City, Exampleland], + contacts: ( + (text: [nova\@candidate.invalid], link: "mailto:nova@candidate.invalid"), + (text: [nova.candidate.invalid], link: "https://nova.candidate.invalid"), + ), +) + +#show: cv.with( + profile: profile, + columns: 2, + left: [ + #section("Research Focus") + #summary[Imaginary governance and simulated decision engines.] + #section-gap + + #section("Methods") + #detail-line(title: "Methods", content: [synthetic trials, mock audits]) + ], + right: [ + #section("Academic Appointments") + #experience( + title: "Lecturer in Speculative Systems", + institution: [Exampleland University], + location: "Sample City, Exampleland", + date: "20XY-present", + ) + ], +) +``` + +Keep this variant concise and preferably to one page. The left and right +columns are visually independent, but text extraction and screen readers may +not preserve the intended reading order. + +## Common recipes + +### Hide the footer date + +```typst +#show: cv.with( + profile: profile, + options: (last-updated: false), +) +``` + +### Use inline contacts + +Without a photo, this changes the right-hand contact column into a wrapping +inline list. With a photo, inline contacts below the identity are already the +default. + +```typst +#show: cv.with( + profile: profile, + layout: (contact-layout: "inline"), +) +``` + +### Place contacts beside a photo + +Use a `profile` that includes `photo`, as in the example above: + +```typst +#show: cv.with( + profile: profile, + layout: (contact-layout: "rail"), +) +``` + +The contact column can wrap, and the photo keeps its configured dimensions. +Without a photo, `"rail"` falls back to stacked contacts. + +### Give the header more room + +```typst +#show: cv.with( + profile: profile, + layout: (header-height: 24mm), +) +``` + +This reserves at least 24 mm before the divider. Taller content still expands +the header; reducing this value does not compress the contacts. For a matching +CV and letter, use the same minimum with profiles and layouts that fit inside +it. Different content or photo layouts can still require different heights. + +### Disable continuation headers + +```typst +#show: cv.with( + profile: profile, + layout: (continue-header: false), +) +``` + +Continuation headers are useful for an academic CV that runs beyond one page. +They repeat only the candidate name, document label, and page count—not the full +contact block. + +## Advanced configuration + +Most documents never need this section. When you do need a specific override, +the grouped API keeps optional settings separate from content: + +| Group | Settings | +| --- | --- | +| `theme` | `font`, colours (`text`, `muted`, `heading`, `accent`, `rule`), and individual size tokens | +| `layout` | `preset`, `margin`, `continue-header`, `contact-layout`, minimum `header-height`, individual rhythm gaps, plus `columns` and `column-gutter` for the two-column variant | +| `options` | `last-updated`, `page-count`, `date` | + +```typst +#show: cv.with( + profile: profile, + theme: ( + font: "Libertinus Serif", + accent: rgb("#1e3a5f"), + ), + layout: ( + preset: "default", + margin: (left: 1.7cm, right: 1.7cm, top: 1.5cm, bottom: 1.5cm), + continue-header: false, + ), + options: ( + last-updated: true, + page-count: true, + date: "2026-07-09", + ), +) +``` + +For exceptional cases, the rhythm gaps map directly to the visual hierarchy: +`section-content-gap` separates a section heading from its first block, +`entry-row-gap` separates a bold entry title from its institution line, +`description-gap` separates that line from a description or bullet list, and +`item-gap` separates complete entries. The default preset intentionally leaves +more space inside a block than the compact preset so dense academic content +still scans as a sequence of distinct records. + +`continue-header` defaults to `true` for `cv` and `cv-single`. The full identity +header appears only on the first page; later pages receive a compact header with +the candidate name, "Curriculum vitae", and page count instead of repeating +contact details. `cv-double` keeps the historical `false` default because it is +intended as a one-page summary. + +### Optional section ordering + +Direct source order is the simplest approach. If a generated workflow needs to +reorder or conditionally hide sections, use `section-block` and +`render-sections`: + +```typst +#let sections = ( + section-block("profile", title: "Research Profile")[ + #summary[Research summary.] + ], + section-block("education", title: "Education")[ + #education( + institution: [Placeholder Institute], + major: [PhD in Speculative Systems], + date: "20XS-20XW", + location: "Demo Harbour, Exampleland", + ) + ], +) + +#render-sections( + sections: sections, + order: ("profile", "education"), + include-remaining: false, +) +``` + +This is an advanced option; it is not required for an ordinary CV. + +### BibTeX publications + +For a short selected-publications section, `entry` is usually easiest. To cite +records from `bib.bib`, list the citation keys and keep the hidden bibliography +at the end of the document: + +```typst +#section("Publications") ++ @article-key ++ @another-key + +#show bibliography: none +#bibliography("bib.bib", style: "chicago-author-date") +``` + +The legacy `publication` helper remains available. Because imported packages +cannot construct paths into the calling project, resolve the path in the CV +file before passing it to the helper: + +```typst +#publication(path("bib.bib"), "chicago-author-date") +``` + +The helper also accepts raw bibliography bytes or an array of resolved paths +and bytes. It rejects bare path strings because those would resolve inside the +installed package rather than beside the CV document. + +### Legacy API + +Nothing from 1.x was removed. `cv-single` and `cv-double` still work, as do the +flat arguments `font-type`, `name`, `address`, `contacts`, `continue-header`, +`lastupdated`, and `pagecount`, including string booleans such as `"true"`. +`layout: (density: ...)` remains an accepted spelling of `preset`. + +Older helper names are aliases: + +- `descript` -> `summary` +- `job` -> `experience` +- `twoline-item` -> `entry` +- `oneline-title-item`, `skill-line` -> `detail-line` +- `references` -> `reference-list` +- `sectionsep` -> `section-gap` +- `subsectionsep` -> `item-gap` + +Use `cv`, the four settings, and the semantic helper names for new documents. + +## Upgrading from 1.x + +Your existing documents keep compiling. They will look different: 2.0.0 replaces +the PT Sans / PT Serif pairing with a single serif family, rebuilds the size +ladder, moves location out of the bottom-right corner, and widens the page +margins to 2.2cm to match the letter template. To modernise a 1.x document, +replace `cv-single.with` with `cv.with` and `layout: (density: "balanced")` with +nothing at all — `"default"` is the default. + +## Troubleshooting + +- **The email causes a syntax error:** escape `@` as `\@` inside Typst content. +- **The header takes more space after adding contacts:** it expands to keep + every item readable. A smaller `header-height` does not shrink its content; + shorten labels or choose `preset: "compact"` if space is tight. +- **Font Awesome icons do not render:** install the corresponding desktop fonts + or remove the optional `icon` fields. +- **The CV feels too dense:** try `preset: "relaxed"` before changing individual + spacing tokens. +- **The CV exceeds a page limit:** remove low-value detail first, then use + `preset: "compact"`. +- **Copied PDF text is out of order:** submit the single-column layout. +- **A font is unavailable:** use `theme: (font: "Libertinus Serif")` for a + broadly available serif fallback. + +## Local development + +See the [source repository's development guide](https://github.com/jxpeng98/Typst-CV-Resume#local-development) for working-template imports, examples, and header regression checks. + +## Release notes + +See the [changelog](CHANGELOG.md) for version history and migration notes. + +## License + +This template is released under the MIT License. See [LICENSE](LICENSE). diff --git a/packages/preview/modernpro-cv/2.1.2/modernpro-cv.typ b/packages/preview/modernpro-cv/2.1.2/modernpro-cv.typ new file mode 100644 index 0000000000..f458f96a2d --- /dev/null +++ b/packages/preview/modernpro-cv/2.1.2/modernpro-cv.typ @@ -0,0 +1,1051 @@ +/////////////////////////////// +// modernpro-cv.typ +// A clean, modern academic CV template +// Copyright (c) 2026 +// Author: Academic Template Collective +// License: MIT +// Version: 2.1.2 +// Date: 2026-09-29 +// Email: maintainers@example.invalid +/////////////////////////////// + +// Shared design tokens for the academic suite: one serif family, two weights, +// a restrained size ladder, four colours, and one rhythm system. Keep this block in sync with +// modernpro-coverletter.typ so a CV and a letter read as one document set. +#let default-cv-style = ( + text: rgb("#1f2933"), + muted: rgb("#667085"), + heading: rgb("#1f2933"), + subheading: rgb("#1f2933"), + accent: rgb("#1e3a5f"), + rule: rgb("#dde3ea"), + + heading-font: ("PT Serif", "Libertinus Serif"), + name-size: 18pt, + item-title-size: 10.5pt, + role-size: 10.5pt, + section-size: 9.8pt, + body-size: 10pt, + meta-size: 9.8pt, + address-size: 9.8pt, + reference-size: 9.8pt, + small-size: 8.8pt, + contact-size: 8.8pt, + footer-size: 8.4pt, + contact-icon-size: 7.6pt, + contact-icon-width: 9pt, + contact-icon-gap: 4pt, + photo-width: 16mm, + photo-height: 20mm, + + section-gap: 1.3em, + section-content-gap: 0.85em, + item-gap: 1.2em, + entry-row-gap: 0.7em, + description-gap: 0.78em, + header-row-gap: 3pt, + header-rule-gap: 7pt, + header-content-gap: 12pt, + body-leading: 0.55em, + list-spacing: 0.78em, + rule-stroke: 0.4pt, + section-tracking: 0.08em, +) + +// One setting controls the document's vertical rhythm. Individual layout keys +// can still override a preset when a document needs a local adjustment. +#let cv-rhythm(preset) = if preset == "compact" { + ( + section-gap: 0.72em, + section-content-gap: 0.38em, + item-gap: 0.62em, + entry-row-gap: 0.3em, + description-gap: 0.36em, + header-row-gap: 1.8pt, + header-rule-gap: 4.8pt, + header-content-gap: 7pt, + body-leading: 0.42em, + list-spacing: 0.48em, + ) +} else if preset == "relaxed" or preset == "spacious" { + ( + section-gap: 1.6em, + section-content-gap: 1.05em, + item-gap: 1.5em, + entry-row-gap: 0.88em, + description-gap: 0.96em, + header-row-gap: 3.6pt, + header-rule-gap: 8pt, + header-content-gap: 14pt, + body-leading: 0.68em, + list-spacing: 0.95em, + ) +} else { + ( + section-gap: default-cv-style.section-gap, + section-content-gap: default-cv-style.section-content-gap, + item-gap: default-cv-style.item-gap, + entry-row-gap: default-cv-style.entry-row-gap, + description-gap: default-cv-style.description-gap, + header-row-gap: default-cv-style.header-row-gap, + header-rule-gap: default-cv-style.header-rule-gap, + header-content-gap: default-cv-style.header-content-gap, + body-leading: default-cv-style.body-leading, + list-spacing: default-cv-style.list-spacing, + ) +} + +#let cv-style = state("modernpro-cv-style", default-cv-style) + +#let is-filled(value) = value != none and value != [] and value != "" + +#let as-bool(value) = value == true or value == "true" + +#let _option(source, key, default) = if source == none { + default +} else { + source.at(key, default: default) +} + +#let _option-any(source, keys, default) = { + let value = default + if source != none { + for key in keys { + value = source.at(key, default: value) + } + } + value +} + +#let _first-filled(values, default: none) = { + for candidate in values { + if is-filled(candidate) { + return candidate + } + } + return default +} + +// A smaller date set beside a larger title aligns on cap-height by default, +// which leaves its baseline floating. Shifting by the cap-height difference +// restores a shared baseline and survives a title that wraps. +#let _cap-shift(style) = 0.7 * (style.item-title-size - style.small-size) + +// Structural gaps are weak so an entry's trailing gap and the following +// section gap collapse to the larger value instead of stacking. +#let sectionsep = context { + let style = cv-style.get() + v(style.section-gap, weak: true) +} + +#let subsectionsep = context { + let style = cv-style.get() + v(style.item-gap, weak: true) +} + +// Section headings (Education, Experience, etc). Case, weight, colour, and the +// trailing rule carry the hierarchy so the size ladder stays at four steps. +#let section(title) = { + context { + let style = cv-style.get() + let label = { + show heading: it => it.body + heading(level: 1)[ + #text( + style.section-size, + fill: style.accent, + weight: "bold", + tracking: style.section-tracking, + )[#upper[#title]] + ] + } + block(sticky: true, above: 0pt, below: 0pt)[ + #grid( + columns: (auto, 1fr), + column-gutter: 0.7em, + align: horizon, + label, + line(length: 100%, stroke: style.rule-stroke + style.rule), + ) + ] + v(style.section-content-gap) + } +} + +// Subsection headings (institution, company, etc). +#let subsection(content) = { + context { + let style = cv-style.get() + text(style.item-title-size, fill: style.subheading, weight: "bold")[#content] + } +} + +// Entry header: a left block carrying the title and its qualifiers, and a right +// rail carrying nothing but the date. Location folds into the left meta line so +// the right edge stays a single clean column. +#let _entry-header( + title: none, + date: none, + meta: none, + location: none, +) = { + context { + let style = cv-style.get() + // Keep the institution or degree in regular text and use colour, rather + // than another italic layer, to quieten the dense middle of an academic CV. + let qualifier = if is-filled(meta) and is-filled(location) { + [ + #text(style.meta-size, fill: style.text)[#meta] + #text(style.meta-size, fill: style.muted)[ · #location] + ] + } else if is-filled(meta) { + text(style.meta-size, fill: style.text)[#meta] + } else if is-filled(location) { + text(style.meta-size, fill: style.muted)[#location] + } else { + none + } + + if is-filled(title) or is-filled(date) or is-filled(qualifier) { + // Implicit paragraph spacing would swamp the rhythm tokens, so every gap + // inside an entry is an explicit v() and the blocks contribute none. + block(breakable: false, sticky: true, above: 0pt, below: 0pt)[ + #block(above: 0pt, below: 0pt)[ + #grid( + columns: (1fr, auto), + column-gutter: 1em, + [ + #if is-filled(title) { + text(style.item-title-size, fill: style.subheading, weight: "bold")[#title] + } + ], + [ + #if is-filled(date) { + v(_cap-shift(style)) + align(right, text(style.small-size, fill: style.muted)[#date]) + } + ], + ) + ] + #if is-filled(qualifier) { + v(style.entry-row-gap) + block(above: 0pt, below: 0pt)[ + #qualifier + ] + } + ] + } + } +} + +#let _entry-description(content) = { + if is-filled(content) { + context { + let style = cv-style.get() + // Neutralize implicit block margins so the shared rhythm tokens remain + // the single source of truth for metadata, descriptions, and lists. + show list: set block(above: 0pt, below: 0pt) + show enum: set block(above: 0pt, below: 0pt) + v(style.description-gap) + block(above: 0pt, below: 0pt)[ + #text(style.body-size, fill: style.text)[#content] + ] + } + } +} + +#let _entry-finish() = context { + let style = cv-style.get() + v(style.item-gap, weak: true) +} + +// Education part +#let education(institution: none, major: none, date: none, location: none, description: none) = { + _entry-header(title: institution, date: date, meta: major, location: location) + _entry-description(description) + _entry-finish() +} + +// Projects +#let project(title, date, info) = { + _entry-header(title: title, date: date) + _entry-description(info) + _entry-finish() +} + +// Summary or short description. +#let summary(content) = { + context { + let style = cv-style.get() + text(style.body-size, fill: style.text)[#content ] + } +} + +// Backward-compatible name. +#let descript = summary + +// Job title +#let job(position: none, institution: none, location: none, date: none, description: none) = { + _entry-header(title: position, date: date, meta: institution, location: location) + _entry-description(description) + _entry-finish() +} + +// Recommended semantic name for job entries. +#let experience( + title: none, + position: none, + institution: none, + organization: none, + location: none, + date: none, + details: none, + description: none, +) = job( + position: _first-filled((title, position)), + institution: _first-filled((institution, organization)), + location: location, + date: date, + description: _first-filled((details, description)), +) + +// Details +#let info(content) = { + context { + let style = cv-style.get() + text(style.body-size, fill: style.text)[#content\ ] + } +} + +#let oneline-title-item(title: none, content: none) = { + context { + let style = cv-style.get() + if is-filled(title) { + text(style.body-size, fill: style.subheading, weight: "bold")[#title: ] + } + if is-filled(content) { + text(style.body-size, fill: style.text)[#content \ ] + } + } +} + +#let detail-line = oneline-title-item +#let skill-line = detail-line + +#let oneline-two(entry1: none, entry2: none) = { + context { + let style = cv-style.get() + let left-content = if entry1 != none { entry1 } else { [] } + let right-content = if entry2 != none { entry2 } else { [] } + + grid( + columns: (1fr, auto), + column-gutter: 1em, + text(style.body-size, fill: style.text)[#left-content], + align(right, text(style.small-size, fill: style.muted)[#right-content]), + ) + } +} + +#let twoline-item(entry1: none, entry2: none, entry3: none, entry4: none, description: none) = { + _entry-header(title: entry1, date: entry2, meta: entry3, location: entry4) + _entry-description(description) + _entry-finish() +} + +#let entry( + title: none, + left: none, + right: none, + meta: none, + location: none, + details: none, + description: none, +) = twoline-item( + entry1: _first-filled((title, left)), + entry2: right, + entry3: meta, + entry4: location, + description: _first-filled((details, description)), +) + +#let award(award: none, institution: none, date: none) = { + context { + let style = cv-style.get() + let label = if is-filled(award) and is-filled(institution) { + [#award · #institution] + } else { + _first-filled((award, institution)) + } + grid( + columns: (1fr, auto), + column-gutter: 1em, + text(style.body-size, fill: style.text)[#label], + align(right, text(style.small-size, fill: style.muted)[#date]), + ) + v(style.entry-row-gap, weak: true) + } +} + +#let references(references: (), columns: (1fr, 1fr)) = { + context { + let style = cv-style.get() + grid( + columns: columns, + column-gutter: 1.4em, + row-gutter: style.item-gap, + ..references.map(reference => block(breakable: false)[ + #align(left, { + // Name and role occupy separate lines so every reference block has the + // same line count and the columns stay aligned regardless of length. + text(style.reference-size, fill: style.subheading, weight: "bold")[ + #reference.name\ + ] + if ("position" in reference) { + text(style.reference-size, fill: style.text, style: "italic")[#reference.position\ ] + } + + if ("department" in reference) { + text(style.reference-size, fill: style.text)[#reference.department\ ] + } + if ("institution" in reference) { + text(style.reference-size, fill: style.text)[#reference.institution\ ] + } + if ("address" in reference) { + text(style.small-size, fill: style.muted)[#reference.address\ ] + } + if ("email" in reference) { + link("mailto:" + reference.email)[ + #text(style.small-size, fill: style.accent)[#reference.email] + ] + } + }) + ]), + ) + } +} + +#let reference-list = references + +// Paths constructed inside an imported package are confined to that package. +// Require callers to resolve bibliography resources in their own document with +// `path("bib.bib")`, or to pass raw bytes, before crossing the package boundary. +#let _is-portable-bibliography-source(source) = if type(source) == array { + source.all(item => type(item) == path or type(item) == bytes) +} else { + type(source) == path or type(source) == bytes +} + +// Publications +#let publication(path, styletype) = { + assert( + _is-portable-bibliography-source(path), + message: "publication source must be created in the calling document with path(\"bib.bib\"), or supplied as raw bytes", + ) + context { + let style = cv-style.get() + set text(style.body-size, fill: style.text) + bibliography(path, title: none, full: true, style: styletype) + } +} + +// Contact icons are optional content supplied by the document. The core +// template deliberately does not import or require an icon package. +#let _contact-has-icon(contact) = ( + type(contact) == dictionary + and ("icon" in contact) + and is-filled(contact.icon) +) + +#let _contact-label(contact, style) = layout(size => { + // Only oversized tokens become breakable boxes, keeping copied labels free + // of added characters. Link destinations and short labels stay intact. + set text(hyphenate: false) + show regex("\\S+"): it => context { + if measure(it).width <= size.width { it } else { + it.text.matches(regex("[^./@_-]+[./@_-]?|[./@_-]")).map(part => { + if measure(text(part.text)).width > size.width { + part.text.clusters().map(char => box(char)).join() + } else { box(part.text) } + }).join() + } + } + let label = if type(contact) == dictionary { + _option(contact, "text", []) + } else { + contact + } + let rendered = text( + fill: if type(contact) == dictionary and ("link" in contact) and is-filled(contact.link) { + style.accent + } else { + style.muted + }, + )[#label] + + if type(contact) == dictionary and ("link" in contact) and is-filled(contact.link) { + link(contact.link)[#rendered] + } else { + rendered + } +}) + +#let _contact-icon(contact, style) = if _contact-has-icon(contact) { + context { + // Center the visible glyph in the label's first line, independent of the + // icon font's metrics or a caller's legacy top-edge: "baseline" setting. + let icon = { + show text: it => text(top-edge: "bounds", bottom-edge: "bounds", it) + text(style.contact-icon-size, fill: style.accent)[#_option(contact, "icon", [])] + } + box(height: measure(text(style.contact-size)[M]).height, align(horizon, icon)) + } +} else { + [] +} + +// Inline contact details, used by the optional compact header mode. +#let contact-display(contacts) = { + context { + let style = cv-style.get() + set text(style.contact-size, fill: style.muted) + layout(size => contacts.map(contact => { + let item = if _contact-has-icon(contact) { + grid( + columns: (auto, auto), + column-gutter: style.contact-icon-gap, + align: left + top, + _contact-icon(contact, style), + _contact-label(contact, style), + ) + } else { + _contact-label(contact, style) + } + // Keep short items together; long labels wrap inside the available width. + box(width: calc.min(size.width, measure(item).width), item) + }).join(" · ")) + } +} + +// Stacked contact details use a narrow, fixed icon column only when at least +// one contact supplies an icon. Text-only profiles keep the original layout. +#let contact-stack(contacts) = { + context { + let style = cv-style.get() + set text(style.contact-size, fill: style.muted) + + let has-icons = false + for contact in contacts { + if _contact-has-icon(contact) { + has-icons = true + } + } + + if has-icons { + let cells = () + for contact in contacts { + cells += ( + align(center + top, _contact-icon(contact, style)), + align(left + top, _contact-label(contact, style)), + ) + } + grid( + columns: (style.contact-icon-width, auto), + column-gutter: style.contact-icon-gap, + row-gutter: style.header-row-gap, + ..cells, + ) + } else { + grid( + columns: auto, + row-gutter: style.header-row-gap, + ..contacts.map(contact => align(right, _contact-label(contact, style))), + ) + } + } +} + +#let section-gap = sectionsep +#let item-gap = subsectionsep + +#let section-block( + id, + title: none, + separator: true, + body, +) = ( + id: id, + content: { + if title != none { + section(title) + } + body + if separator { + sectionsep + } + }, +) + +#let render-sections( + sections: (), + order: none, + include-remaining: true, +) = { + if order == none { + for entry in sections { + entry.content + } + } else { + for id in order { + let entry = sections + .filter(section => section.id == id) + .at(0, default: none) + if entry != none { + entry.content + } + } + if include-remaining { + for entry in sections { + if not order.contains(entry.id) { + entry.content + } + } + } + } +} + +#let resolve-cv-config( + font-type, + continue-header, + margin, + name, + address, + lastupdated, + pagecount, + date, + contacts, + profile: none, + theme: none, + layout: none, + options: none, + preset: none, + accent: none, + columns: none, + default-bottom: 1.3cm, +) = { + let resolved-preset = _first-filled( + (preset, _option-any(layout, ("density", "preset"), none)), + default: "default", + ) + let rhythm = cv-rhythm(resolved-preset) + let resolved-date = _option(options, "date", date) + if resolved-date == none { + resolved-date = datetime.today().display() + } + let resolved-continue-header = as-bool(_option-any(layout, ("continue-header", "continued-header"), continue-header)) + let resolved-margin = _option(layout, "margin", margin) + if resolved-margin == none { + // The first-page masthead must not move when continuation headers are + // toggled. These dimensions are shared with modernpro-coverletter. + resolved-margin = ( + left: 2.2cm, + right: 2.2cm, + top: 2cm, + bottom: default-bottom, + ) + } + let resolved-accent = _first-filled( + (accent, _option(theme, "accent", none)), + default: default-cv-style.accent, + ) + let resolved-photo = _option(profile, "photo", none) + + let style = ( + text: _option(theme, "text", default-cv-style.text), + muted: _option(theme, "muted", default-cv-style.muted), + heading: _option-any(theme, ("heading", "headings"), default-cv-style.heading), + subheading: _option-any(theme, ("subheading", "subheadings"), default-cv-style.subheading), + accent: resolved-accent, + rule: _option(theme, "rule", default-cv-style.rule), + heading-font: _option-any(theme, ("heading-font", "display-font"), default-cv-style.heading-font), + body-size: _option(theme, "body-size", default-cv-style.body-size), + item-title-size: _option(theme, "item-title-size", default-cv-style.item-title-size), + meta-size: _option(theme, "meta-size", default-cv-style.meta-size), + small-size: _option(theme, "small-size", default-cv-style.small-size), + section-size: _option(theme, "section-size", default-cv-style.section-size), + section-tracking: _option(theme, "section-tracking", default-cv-style.section-tracking), + name-size: _option(theme, "name-size", default-cv-style.name-size), + role-size: _option(theme, "role-size", default-cv-style.role-size), + address-size: _option(theme, "address-size", default-cv-style.address-size), + contact-size: _option(theme, "contact-size", default-cv-style.contact-size), + contact-icon-size: _option(theme, "contact-icon-size", default-cv-style.contact-icon-size), + contact-icon-width: _option( + layout, + "contact-icon-width", + _option(theme, "contact-icon-width", default-cv-style.contact-icon-width), + ), + contact-icon-gap: _option( + layout, + "contact-icon-gap", + _option(theme, "contact-icon-gap", default-cv-style.contact-icon-gap), + ), + photo-width: _option(theme, "photo-width", default-cv-style.photo-width), + photo-height: _option(theme, "photo-height", default-cv-style.photo-height), + footer-size: _option(theme, "footer-size", default-cv-style.footer-size), + reference-size: _option(theme, "reference-size", default-cv-style.reference-size), + section-gap: _option(layout, "section-gap", _option(theme, "section-gap", rhythm.section-gap)), + section-content-gap: _option(layout, "section-content-gap", _option(theme, "section-content-gap", rhythm.section-content-gap)), + item-gap: _option(layout, "item-gap", _option(theme, "item-gap", rhythm.item-gap)), + entry-row-gap: _option(layout, "entry-row-gap", _option(theme, "entry-row-gap", rhythm.entry-row-gap)), + description-gap: _option(layout, "description-gap", _option(theme, "description-gap", rhythm.description-gap)), + header-row-gap: _option(layout, "header-row-gap", rhythm.header-row-gap), + header-rule-gap: _option(layout, "header-rule-gap", rhythm.header-rule-gap), + header-content-gap: _option(layout, "header-content-gap", rhythm.header-content-gap), + body-leading: _option(layout, "body-leading", rhythm.body-leading), + list-spacing: _option(layout, "list-spacing", rhythm.list-spacing), + rule-stroke: _option(layout, "rule-stroke", _option(theme, "rule-stroke", default-cv-style.rule-stroke)), + ) + + ( + font-type: _first-filled( + (_option-any(theme, ("font-type", "font"), none), font-type), + default: default-cv-style.heading-font, + ), + style: style, + continue-header: resolved-continue-header, + margin: resolved-margin, + name: _option(profile, "name", name), + role: _option-any(profile, ("role", "headline", "position"), none), + address: _option(profile, "address", address), + contacts: _option(profile, "contacts", contacts), + photo: resolved-photo, + lastupdated: as-bool(_option-any(options, ("lastupdated", "last-updated"), lastupdated)), + pagecount: as-bool(_option-any(options, ("pagecount", "page-count"), pagecount)), + date: resolved-date, + columns: _option(layout, "columns", (1fr, 2fr)), + column-gutter: _option(layout, "column-gutter", 1.8em), + contact-layout: _option(layout, "contact-layout", "stacked"), + preset: resolved-preset, + header-height: _option( + layout, + "header-height", + if is-filled(resolved-photo) { style.photo-height } else { 17mm }, + ), + header-ascent: _option(layout, "header-ascent", 0.8em), + ) +} + +#let cv-footer(cfg) = context { + let style = cfg.style + let current-page = counter(page).get().first() + let show-page = cfg.pagecount and (not cfg.continue-header or current-page == 1) + set text(style.footer-size, fill: style.muted) + grid( + columns: (1fr, auto), + column-gutter: 1em, + align: horizon, + [#if cfg.lastupdated and current-page == 1 [Last updated: #cfg.date]], + align(right)[ + #if show-page [#counter(page).display("1 / 1", both: true)] + ], + ) +} + +#let cv-header(cfg) = { + let style = cfg.style + // Typst measures a line box down to the baseline, so descenders hang outside + // it and a tight stack collides. Extending the bottom edge fixes the whole + // header at once, for every document in the suite. + set text(bottom-edge: "descender") + set par(spacing: 0pt, first-line-indent: 0em) + + let identity = ( + if is-filled(cfg.name) { + align(left, text(style.name-size, fill: style.heading, weight: "bold")[#cfg.name]) + }, + if is-filled(cfg.role) { + align(left, text(style.role-size, fill: style.accent, weight: "bold")[#cfg.role]) + }, + if is-filled(cfg.address) { + align(left, text(style.address-size, fill: style.muted)[#cfg.address]) + }, + ).filter(item => item != none) + + let contact-block = if cfg.contacts == none or cfg.contacts.len() == 0 { + none + } else if cfg.contact-layout == "inline" { + contact-display(cfg.contacts) + } else { + contact-stack(cfg.contacts) + } + let has-photo = is-filled(cfg.photo) + let photo-frame = if has-photo { + box( + width: style.photo-width, + height: style.photo-height, + inset: 0pt, + clip: true, + )[ + #align(center + horizon, cfg.photo) + ] + } + let identity-block = grid( + columns: 1fr, + row-gutter: style.header-row-gap, + ..identity, + ) + let header-content(vertical) = if has-photo and cfg.contact-layout == "rail" { + let utility = if contact-block == none { [] } else { contact-block } + layout(size => context { + let contact-width = calc.min( + measure(utility).width, + (size.width - style.photo-width - 2.4em.to-absolute()) / 2.08, + ) + grid( + columns: (1fr, auto), + column-gutter: 1.4em, + align: vertical, + align(left + vertical, identity-block), + align(right + vertical, grid( + columns: (contact-width, style.photo-width), + column-gutter: 1em, + align: vertical, + utility, + photo-frame, + )), + ) + }) + } else if has-photo { + grid( + columns: (1fr, auto), + column-gutter: 1.4em, + align: top, + [ + #identity-block + #if contact-block != none { + v(style.header-row-gap) + align(left, contact-display(cfg.contacts)) + } + ], + align(right + top, photo-frame), + ) + } else { + grid( + columns: (1.08fr, 1fr), + column-gutter: 1.4em, + align: vertical, + identity-block, + align(right + vertical, [ + #if contact-block != none { + contact-block + } + ]), + ) + } + + block(breakable: false)[ + #layout(size => context { + let compact-align = if has-photo and cfg.contact-layout == "rail" { horizon } else { bottom } + let content = header-content(compact-align) + let minimum = measure(box(height: cfg.header-height), width: size.width, height: size.height).height + // Align tall headers from the top, keeping the name beside the first contact. + if measure(content, width: size.width).height > minimum { + content = header-content(top) + } + grid( + columns: (0pt, 1fr), + column-gutter: 0pt, + align: bottom, + box(height: cfg.header-height), + content, + ) + }) + #v(style.header-rule-gap) + #line(length: 100%, stroke: style.rule-stroke + style.accent) + #v(style.header-content-gap) + ] +} + +#let cv-continuation-header(cfg) = { + let style = cfg.style + let document-label = if cfg.pagecount { + [Curriculum vitae · #counter(page).display("1 / 1", both: true)] + } else { + [Curriculum vitae] + } + block(breakable: false)[ + #grid( + columns: (1fr, auto), + column-gutter: 1em, + text(style.contact-size, fill: style.heading, weight: "bold")[#cfg.name], + text(style.contact-size, fill: style.muted)[#document-label], + ) + #v(0.3em) + #line(length: 100%, stroke: style.rule-stroke + style.rule) + ] +} + +#let render-cv-page(cfg, body) = { + set page(footer: cv-footer(cfg)) + if cfg.continue-header { + set page( + margin: cfg.margin, + header: context { + if counter(page).get().first() > 1 { + cv-continuation-header(cfg) + } + }, + header-ascent: cfg.header-ascent, + ) + cv-header(cfg) + body + } else { + set page(margin: cfg.margin) + cv-header(cfg) + body + } +} + +// Single entry point. The document body arrives positionally through the show +// rule; `columns: 2` ignores it and renders the `left` and `right` bodies. +#let cv( + profile: none, + preset: none, + accent: none, + columns: 1, + left: none, + right: none, + font-type: none, + continue-header: "true", + margin: none, + name: none, + address: none, + lastupdated: "true", + pagecount: "true", + date: none, + contacts: (), + theme: none, + layout: none, + options: none, + mainbody, +) = { + let cfg = resolve-cv-config( + font-type, + continue-header, + margin, + name, + address, + lastupdated, + pagecount, + date, + contacts, + profile: profile, + theme: theme, + layout: layout, + options: options, + preset: preset, + accent: accent, + columns: columns, + default-bottom: if columns == 2 { 1.5cm } else { 1.3cm }, + ) + + cv-style.update(cfg.style) + set text(font: cfg.font-type, size: cfg.style.body-size, fill: cfg.style.text) + // Only consecutive paragraphs use this; explicit rhythm gaps drop the weak + // paragraph spacing next to them, so it never doubles up on a token. + set par(leading: cfg.style.body-leading, spacing: 0.9em) + set list(indent: 1em, body-indent: 0.45em, spacing: cfg.style.list-spacing) + show link: set text(fill: cfg.style.accent) + set cite(form: "full") + + let body = if columns == 2 { + grid( + columns: cfg.columns, + column-gutter: cfg.column-gutter, + left, right, + ) + } else { + mainbody + } + + render-cv-page(cfg, body) +} + +#let cv-single( + font-type: none, + continue-header: "true", + margin: none, + name: none, + address: none, + lastupdated: "true", + pagecount: "true", + date: none, + contacts: (), + profile: none, + preset: none, + accent: none, + theme: none, + layout: none, + options: none, + mainbody, +) = cv( + profile: profile, + preset: preset, + accent: accent, + columns: 1, + font-type: font-type, + continue-header: continue-header, + margin: margin, + name: name, + address: address, + lastupdated: lastupdated, + pagecount: pagecount, + date: date, + contacts: contacts, + theme: theme, + layout: layout, + options: options, + mainbody, +) + +// Kept without a trailing positional so the historical `#show: cv-double(...)` +// call form keeps working alongside `#show: cv.with(columns: 2, ...)`. +#let cv-double( + font-type: none, + continue-header: "false", + margin: none, + name: none, + address: none, + lastupdated: "true", + pagecount: "true", + date: none, + contacts: (), + left: none, + right: none, + profile: none, + preset: none, + accent: none, + theme: none, + layout: none, + options: none, +) = cv( + profile: profile, + preset: preset, + accent: accent, + columns: 2, + left: left, + right: right, + font-type: font-type, + continue-header: continue-header, + margin: margin, + name: name, + address: address, + lastupdated: lastupdated, + pagecount: pagecount, + date: date, + contacts: contacts, + theme: theme, + layout: layout, + options: options, + [], +) diff --git a/packages/preview/modernpro-cv/2.1.2/screenshots/cv-double.png b/packages/preview/modernpro-cv/2.1.2/screenshots/cv-double.png new file mode 100644 index 0000000000..8312edc2ed Binary files /dev/null and b/packages/preview/modernpro-cv/2.1.2/screenshots/cv-double.png differ diff --git a/packages/preview/modernpro-cv/2.1.2/screenshots/cv-photo-harmonious.png b/packages/preview/modernpro-cv/2.1.2/screenshots/cv-photo-harmonious.png new file mode 100644 index 0000000000..c8336527a3 Binary files /dev/null and b/packages/preview/modernpro-cv/2.1.2/screenshots/cv-photo-harmonious.png differ diff --git a/packages/preview/modernpro-cv/2.1.2/screenshots/cv-single.png b/packages/preview/modernpro-cv/2.1.2/screenshots/cv-single.png new file mode 100644 index 0000000000..c387936f17 Binary files /dev/null and b/packages/preview/modernpro-cv/2.1.2/screenshots/cv-single.png differ diff --git a/packages/preview/modernpro-cv/2.1.2/template/bib.bib b/packages/preview/modernpro-cv/2.1.2/template/bib.bib new file mode 100644 index 0000000000..e72a6776ac --- /dev/null +++ b/packages/preview/modernpro-cv/2.1.2/template/bib.bib @@ -0,0 +1,25 @@ +% Every entry is an explicit placeholder from the invented setting of Exampleland. + +@article{placeholder2099simulatedreview, + title = {Governing Imaginary Models through Simulated Review}, + author = {Placeholder, Nova and Example, Arin}, + date = {2099}, + journaltitle = {Journal of Imaginary Systems}, + volume = {8}, + number = {2}, + pages = {41--63}, + publisher = {Exampleland Academic Press}, + url = {https://publications.example.invalid/placeholder-example-2099}, +} + +@article{placeholder2097sampleexplanations, + title = {When Sample Explanations Change Fictional Decisions}, + author = {Placeholder, Nova and Sample, Robin and Demo, Taylor}, + date = {2097}, + journaltitle = {Simulated Governance Review}, + volume = {12}, + number = {4}, + pages = {117--139}, + publisher = {Placeholder Scholarly Society}, + url = {https://publications.example.invalid/placeholder-sample-demo-2097}, +} diff --git a/packages/preview/modernpro-cv/2.1.2/template/cv-double.typ b/packages/preview/modernpro-cv/2.1.2/template/cv-double.typ new file mode 100644 index 0000000000..1b0636a32c --- /dev/null +++ b/packages/preview/modernpro-cv/2.1.2/template/cv-double.typ @@ -0,0 +1,84 @@ +#import "@preview/modernpro-cv:2.1.2": * +// Edit identity and contacts here. Keeping them beside the CV content makes +// this starter a self-contained document with no application-specific imports. +#let profile = ( + name: [Your Name], + role: [Your Current Role], + address: [City, Country], + // photo: image("portrait.jpg", width: 16mm, height: 20mm, fit: "cover", alt: "Portrait of Your Name"), + contacts: ( + (text: [name\@candidate.invalid], link: "mailto:name@candidate.invalid"), + (text: [site.candidate.invalid], link: "https://site.candidate.invalid"), + (text: [Fictional ID~0000-0000], link: "https://registry.example.invalid/0000-0000"), + ), +) + +// Two-column variant. Prefer cv-single.typ for a full academic CV or for ATS +// parsing; this layout suits a one-page summary. +// +// For icons beside each contact, add `#import "@preview/fontawesome:0.6.2": fa-icon` +// and give any contact in the profile above an `icon:` field. +#show: cv.with( + profile: profile, + columns: 2, + left: [ + #section("Research Focus") + #summary[ + Two or three lines naming your area and the kind of question you ask. + ] + #section-gap + + #section("Methods") + #detail-line(title: "Quantitative", content: [your quantitative methods]) + #detail-line(title: "Qualitative", content: [your qualitative methods]) + #detail-line(title: "Tools", content: [Tool Alpha, Tool Beta, Tool Gamma, Tool Delta]) + #section-gap + + #section("Awards") + #award(award: "Award Name", institution: "Awarding Body", date: "2024") + #section-gap + + #section("Teaching and Service") + #detail-line(title: "Teaching", content: [courses and supervision]) + #detail-line(title: "Service", content: [committees and review work]) + ], + right: [ + #section("Academic Appointments") + #experience( + title: "Your Position", + institution: [Institution], + location: "City, Country", + date: "2023-present", + details: [ + - One line on what you lead, build, or supervise. + ], + ) + #section-gap + + #section("Education") + #education( + institution: [University], + major: [PhD in Your Field], + date: "2016-2020", + location: "City, Country", + description: [Thesis: your thesis title.], + ) + #section-gap + + #section("Selected Publications") + #entry( + title: [Paper title], + right: "2025", + meta: [Author list, Journal Name 8(2)], + ) + #section-gap + + #section("Research Funding") + #entry( + title: [Grant title], + right: "2024-2027", + meta: [Funder; your role], + location: [Amount], + ) + ], +) diff --git a/packages/preview/modernpro-cv/2.1.2/template/cv-single.typ b/packages/preview/modernpro-cv/2.1.2/template/cv-single.typ new file mode 100644 index 0000000000..4a1cc90c45 --- /dev/null +++ b/packages/preview/modernpro-cv/2.1.2/template/cv-single.typ @@ -0,0 +1,92 @@ +#import "@preview/modernpro-cv:2.1.2": * +// Edit identity and contacts here. Keeping them beside the CV content makes +// this starter a self-contained document with no application-specific imports. +#let profile = ( + name: [Your Name], + role: [Your Current Role], + address: [City, Country], + // photo: image("portrait.jpg", width: 16mm, height: 20mm, fit: "cover", alt: "Portrait of Your Name"), + contacts: ( + (text: [name\@candidate.invalid], link: "mailto:name@candidate.invalid"), + (text: [site.candidate.invalid], link: "https://site.candidate.invalid"), + (text: [Fictional ID~0000-0000], link: "https://registry.example.invalid/0000-0000"), + ), +) + +// Single-column academic CV. Everything below `profile` is optional: +// preset: "compact" | "default" | "relaxed" vertical rhythm +// accent: rgb("#1e3a5f") the one colour in the document +// layout: (continue-header: true) compact identity from page 2 +// columns: 2 two-column variant, see cv-double.typ +#show: cv.with(profile: profile) + +#section("Research Profile") +#summary[ + One or two sentences on the question that connects your work and the methods + you use to answer it. +] +#section-gap + +#section("Academic Appointments") +#experience( + title: "Your Position", + institution: [Institution, Department], + location: "City, Country", + date: "2023-present", + details: [ + - One line on what you lead, build, or supervise. + ], +) +#section-gap + +#section("Education") +#education( + institution: [University], + major: [PhD in Your Field], + date: "2016-2020", + location: "City, Country", + description: [Thesis: your thesis title.], +) +#section-gap + +#section("Selected Publications") +#entry( + title: [Paper title], + right: "2025", + meta: [Author list, Journal Name 8(2)], +) +#section-gap + +#section("Research Funding") +#entry( + title: [Grant title], + right: "2024-2027", + meta: [Funder; your role], + location: [Amount], +) +#section-gap + +#section("Teaching and Service") +#detail-line(title: "Teaching", content: [Courses you lead and supervision you provide.]) +#detail-line(title: "Service", content: [Committees, review work, and outreach.]) +#section-gap + +#section("References") +#reference-list(references: ( + ( + name: "Referee Name", + position: "Their Role", + department: "Department", + institution: "Institution", + address: "City, Country", + email: "referee.one@referee.invalid", + ), + ( + name: "Second Referee", + position: "Their Role", + department: "Department", + institution: "Institution", + address: "City, Country", + email: "referee.two@referee.invalid", + ), +)) diff --git a/packages/preview/modernpro-cv/2.1.2/thumbnail.png b/packages/preview/modernpro-cv/2.1.2/thumbnail.png new file mode 100644 index 0000000000..e4f1a3480a Binary files /dev/null and b/packages/preview/modernpro-cv/2.1.2/thumbnail.png differ diff --git a/packages/preview/modernpro-cv/2.1.2/typst.toml b/packages/preview/modernpro-cv/2.1.2/typst.toml new file mode 100755 index 0000000000..5374d5814f --- /dev/null +++ b/packages/preview/modernpro-cv/2.1.2/typst.toml @@ -0,0 +1,15 @@ +[package] +name = "modernpro-cv" +version = "2.1.2" +entrypoint = "modernpro-cv.typ" +authors = [ "Academic Template Collective",] +license = "MIT" +description = "A clean, modern CV template for academic and professional applications." +keywords = [ "cv", "resume", "academic",] +categories = [ "cv",] +exclude = [ "/screenshots/", "/CHANGELOG.md",] + +[template] +path = "template" +entrypoint = "cv-single.typ" +thumbnail = "thumbnail.png"