• Typst 93.1%
  • Just 6.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-14 16:36:42 +02:00
src [1] (feat on main) V1 !!! 2026-08-14 16:36:42 +02:00
template [1] (feat on main) V1 !!! 2026-08-14 16:36:42 +02:00
.gitignore [1] (feat on main) V1 !!! 2026-08-14 16:36:42 +02:00
example-en.pdf [1] (feat on main) V1 !!! 2026-08-14 16:36:42 +02:00
example-fr.pdf [1] (feat on main) V1 !!! 2026-08-14 16:36:42 +02:00
Justfile [1] (feat on main) V1 !!! 2026-08-14 16:36:42 +02:00
README.md [1] (feat on main) V1 !!! 2026-08-14 16:36:42 +02:00
typst.toml [1] (feat on main) V1 !!! 2026-08-14 16:36:42 +02:00

tom-planche-resume-template

A small bilingual Typst resume package. French is the default, English is produced from the same source.

This repository holds two things:

Folder Role
src/ The package. resume, plus every component it draws with.
template/ The starting project. typst init copies it into a new folder.

Your identity and your career never enter this repository. They live in the project you make from the template, which is where you build from.

Install

The package is not published on @preview yet, so link it into the local Typst registry:

just install    # symlinks this folder into ~/Library/Application Support/typst/packages/local/
just status     # shows what the registry holds
just uninstall  # removes the link

It is a symlink, so an edit here is picked up by the next build of any project that imports the package. Nothing to reinstall.

Start a project

just new ~/Desktop/my-resume        # or: typst init @local/tom-planche-resume-template:1.0.0 ~/Desktop/my-resume
cd ~/Desktop/my-resume
just init                           # creates .env from .env.example
just fonts                          # says whether the font the layout wants is installed
$EDITOR .env                        # your name, email and phone
$EDITOR content.typ                 # your career, skills and projects
$EDITOR style.typ                   # how it all looks, if the defaults do not suit you
just all                            # -> resume-fr.pdf and resume-en.pdf

The new folder carries its own Justfile, .gitignore and assets/, and imports the package by name:

#import "@local/tom-planche-resume-template:1.0.0": resume

Once the package is published, that line becomes @preview/tom-planche-resume-template:1.0.0 and nothing else changes.

Build the examples

just example   # -> example-fr.pdf and example-en.pdf

Both PDFs are tracked, so the result is visible without a build. The recipe compiles template/ in a temporary copy, with .env.example under the name .env, so the template needs no .env of its own. Rerun it whenever you change a file under template/, so the tracked PDFs stay in step.

The template uses Cedric Villani as a persona. The career details in template/content.typ are illustrative filler for the layout, not a record of any real person, and every URL points to example.org.

Files

The package

File Role
typst.toml The manifest. Declares both the package and the template.
src/lib.typ The entrypoint: resume, plus every component.
src/vars.typ Every default: typography, colors, and one group per component.
src/theme.typ Merges those defaults with the theme a document passes in.
src/lang.typ Language selection and the t() lookup.
src/shapes.typ Geometry helpers (curved-polygon).
src/components.typ title, section-title, interests, link-boxes, experience, education, skills, projects.
src/assets/icons/ The two icons of the project cards. They travel with the package. The icons are Phosphor; see Credits.

The template

File Role
template/main.typ The entrypoint of a project. Hands the three files below to the package.
template/content.typ Every piece of text. This is the file you edit day to day.
template/style.typ How the resume looks, with every key spelled out, so it doubles as the reference.
template/env.typ Reads and parses .env. The project's, not the package's; see below.
template/.env.example The committed template for .env.
template/Justfile The build recipes of a project: fr, en, all, print, watch-*, fonts, clean.
template/assets/ The placeholder logo, plane and portrait. No font; see below.

.env holds four required keys:

FIRST_NAME=Cédric
LAST_NAME=Villani
EMAIL=cedric.villani@mail.com
PHONE=+33 6 00 00 00 00

A missing key, an empty key, or a missing .env stops the build with a message that names the key.

Fonts

The layout is drawn in PP Supply Mono, and it has to be installed on the machine. It is a commercial font, so this repository does not carry it.

A font cannot travel with the document. Typst resolves one in the compiler, before the document is evaluated, and it looks in three places: the fonts it embeds, the system fonts, and the directories --font-path or TYPST_FONT_PATHS name. Nothing a .typ file says can add a fourth, which is why the font is a name in style.typ and never a path:

#let typography = (font: "PP Supply Mono")

Change that name and the whole resume follows, so a machine without the font is one edit away from building. Typst warns on a font it cannot find, and falls back rather than failing:

warning: unknown font family: pp supply mono

just fonts answers the same question ahead of a build. If you keep the font somewhere other than the system font folder, point TYPST_FONT_PATHS at it: the environment variable covers typst compile, typst watch and the editor preview at once, which a flag on one command does not.

[NOTE] Typst reads .ttf, .otf, .ttc and .otc. It does not read .woff2, so a folder of web fonts is invisible to it whatever you point at it.

Building without just

typst compile main.typ resume-fr.pdf
typst compile main.typ resume-en.pdf --input lang=en
typst compile main.typ --input print=true

An unknown language code fails the compilation with an explicit message, so a typo never silently produces the default language.

The package boundary

A package cannot reach outside itself, and Typst enforces both halves of that:

  • import "../content.typ" fails with path would escape the package root
  • an absolute path inside a package is resolved against the package root, so read("/.env") there would look for .env among the library sources

So the package reads nothing of yours. Text, images and style all come in as arguments to resume, and main.typ is the one place where your files and the package meet. That is also why:

  • env.typ sits in the project, beside main.typ, rather than in src/. There, / means your project.
  • images are read as bytes, with read(path, encoding: none), and handed over. A path string could not make the trip, and bytes let the package keep sizing them, so every size key stays in style.typ.
  • the two icons use paths relative to src/components.typ. A relative path resolves against the file it is written in, which holds whether the package is a folder on disk or installed in the registry.

How .env reaches the document

Typst cannot read shell environment variables. env.typ reads the file itself with read("/.env") and parses the KEY=VALUE lines. The parser skips blank lines, # comments, and any line without an =. It strips one pair of quotes around a value.

The values reach three places:

  • full-name becomes the PDF title and author, through main.typ.
  • first-name and last-name render in the name block, in lower case.
  • email and phone build the mailto: and tel: rows of the links list.

To stop showing the phone number, remove its entry from links in content.typ. The key stays required in .env.

Sizing the name block

The first name and the image beside it share the left column, about 65mm wide. The first name takes the width it needs and the image takes what is left, so a long first name leaves no room and the image runs off the column. The name component, in style.typ, controls this:

#let components = (
    name: (
        size: 45pt,          // font size of both names
        image-height: 40mm,  // the width follows the ratio
        gutter: 4pt,         // least space between the name and the image
    ),
)

Three letters fit 45pt with a 40mm image. Six letters need about 36pt with a 22mm one, which is what the template uses for "cedric". Lower both further for a longer name.

The image itself is name-image, in content.typ, because it is read from your project rather than named here:

#let name-image = picture("/assets/my-image.png")
#let name-image = none    // or no image at all

With none the first name has the row to itself, and the column with it. The template ships assets/placeholder-plane.svg as a stand-in.

Styling

content.typ holds what the resume says. style.typ holds how it looks, and nothing under src/ is ever edited to restyle a resume.

style.typ declares three dictionaries. typography and colors set the document-wide defaults; components overrides them one component at a time:

#let typography = (size: 12pt)
#let colors = (link: rgb("#1D4ED8"))
#let components = (
    page: (vertical: 2.4em),
    skills: (title-size: 12pt, content-color: rgb("#444444")),
    projects: (card-gap: 1.5em, card-radius: 4pt),
)

Every dictionary is optional, and so is every key inside it: a key you leave out keeps its default, and a dictionary you delete keeps every default. A key that matches nothing stops the build and names itself, so a typo never looks like a template that ignores its own overrides.

auto means inherit. A *-font falls back to typography.font, a *-size to typography.size, a *-color to colors.text. Write a real value to break a component away from the document-wide setting.

src/vars.typ holds every default, and src/theme.typ merges them with the theme main.typ passes in. resume then hands the result to every component.

template/style.typ spells out every key with its default, so it doubles as the reference. What follows is the map of the groups.

typography

The page and the document-wide text: paper, margin-x, margin-top, margin-bottom, font, size, leading, justify, linebreaks, list-marker, list-indent, list-spacing.

colors

accent, link, link-print, text and debug-bg. text is what every auto color falls back to, and link-print is the link color once print is on.

[NOTE] accent styles headings and list markers, and this layout emits neither, so it currently has no visible effect. It is wired up and waiting for content that uses one.

components

Group Draws Keys
page The two columns of the page columns, vertical, left-vertical, right-vertical, right-top-gap, column-gutter, section-gap, inner-gap
name The name block font, size, color, image, image-height, gutter, name-gap
headline The tagline under the name font, size, color
portrait The clipped portrait size, sides, bulge
lang-links The line at the top right, linking the other language versions font, size, color, separator, dx, dy, underline
links The bordered boxes of the left column font, size, width, inset, radius, stroke-thickness
interests The interests block title-font, title-size, title-color, font, size, color, link-underline
section-title Every section heading font, size, color, weight, above, below
entry-separator The dotted rule between entries above, below, color, thickness, dash
experience The experience section logo-size, logo-gap, header-gutter, description-above, and title-*, company-*, subtitle-*, description-*
education The education section logo-size, logo-gap, label-*
skills The skills table row-padding, column-gap, title-* (including title-weight), content-*
projects The heading, the intro paragraph and the project cards intro-*, card-gap, icon-gap, icon-size, show-link-icon, content-above, border-color, border-thickness, card-inset, card-radius, title-*, content-*

Every section draws its own heading through section-title, projects included.

page.vertical is the rhythm of the whole page, and everything else in the group derives from it while it is auto, so that one key retunes the layout:

vertical            the shared base
├── left-vertical   the gaps between the blocks of the left column
├── right-vertical  the base of the right column
│   ├── right-top-gap  page top to the "Experiences" heading
│   ├── section-gap    between two sections        (right-vertical / 2)
│   └── inner-gap      around the projects intro   (right-vertical / 3)
└── column-gutter   between the columns            (vertical / 3)

So the two columns can keep their own rhythm without touching each other:

#let components = (
    page: (
        vertical: 3em,
        left-vertical: 2em,   // tighten the left column alone
        right-top-gap: 1em,   // and lift the first heading towards the top edge
    ),
)

The gutter derives from the shared vertical rather than from either side, because it sits between the two.

projects.card-gap is the whole space between two cards, and projects.content-above the whole space inside a card, between the icon and title row and the description under it. Neither adds to the spacing Typst puts between blocks on its own: card-gap defaults to the 3.1em of the line break that used to separate the cards, and content-above to the 1.2em Typst was already applying.

Printing

A page on paper has nothing to click, so the marks that advertise a link are noise. print drops them: the underline under a company name, the underline under an open source contribution, the chain icon on a project card, and the link color. The links stay in the PDF, and stay clickable if the file is opened rather than printed.

just print                                 # resume-fr-print.pdf and resume-en-print.pdf
typst compile main.typ --input print=true

style.typ holds the default with #let print = false, and --input print=true overrides it for one build, so a printable copy never means editing a file and editing it back. An --input print= value other than true or false stops the build rather than quietly meaning "no".

The five keys it drives are company-underline, link-underline, lang-links.underline, show-link-icon and colors.link-print. Each ships as auto, which means follow the mode. Pin one with a real value and it stops following:

#let components = (
    projects: (show-link-icon: true),  // keep the chain icon, even on paper
)

Linking the other language

A build renders one language, so the reader holding the French copy is the one who wants the English one. lang-links, in content.typ, is a URL per language, and the top right of the page gets a link to every language except the one being compiled. Both versions come from the same list, and each points at the other:

#let lang-links = (
    fr: (url: "resume-fr.pdf", label: "Version française"),
    en: (url: "resume-en.pdf", label: "English version"),
)

A bare string is the URL itself; a dictionary carries it under url and may name the label, which otherwise is the language code in upper case. A plain label is written in the language it links to, so it needs no translating, and a (fr: .., en: ..) label is translated like any other value.

A URL may be absolute, or relative to the PDF. resume-en.pdf resolves beside the file in most viewers, which is what a folder holding both versions wants; an absolute URL is what a resume online wants.

Set the whole thing to (:), or leave the argument out of main.typ, and nothing is drawn. A language code that is not in lang.typ stops the build and names itself.

The line is placed out of flow, so it never moves the two columns. It lands in the top right of the text area, in the gap the right column leaves above its first heading. dx and dy nudge it from there, and underline follows the print mode like the other link marks.

Interests

One list in content.typ holds them all, and each entry is translatable and may carry links:

#let interests = (
    "Rust",                            // plain, the same in both languages
    (fr: "Piano", en: "Piano"),        // plain, translated
    (
        label: (fr: "Musique", en: "Music"),   // a link; the label translates
        url: "https://last.fm/user/me",
    ),
    (
        label: (fr: "Programmation Open Source", en: "Open Source"),
        links: (                               // a label, then its own links
            (label: "Zed", url: "https://.."),
            (label: "Clap", url: "https://.."),
        ),
    ),
)

A bare value is the label itself; a dictionary carries it under label, and title is read in its place. url turns the label into a link. links adds a parenthesised list after it, whose entries take the same keys, so one of them may be a link, plain text, or carry links of its own.

Both keys are optional and independent, so an entry is linked, or not, on its own. Every entry gets one line. The underline is interests.link-underline, which follows the print mode like the rest.

Dropping the projects intro

The paragraph above the project cards belongs to the projects component. It reads project-intro from content.typ, and its look is the intro-font, intro-size, intro-color and intro-below keys. intro-below is the gap between the paragraph and the first card, and follows page.inner-gap while it is auto.

To drop it, set the value to none in content.typ, or delete the binding:

#let project-intro = none

The paragraph and the gap under it both go, and the cards move up to meet the heading.

Known limits

A job title has to fit on one line. experience gives the header a fixed height, equal to the company logo, and splits it into two equal rows. A title that wraps overlaps its subtitle.

The link boxes in the left column are 90% of the column width. A long email or URL overflows the border rather than wrapping.

Adding a language

In src/lang.typ, add the code to languages:

#let languages = ("fr", "en", "es")

Then add the matching key to the translated values in content.typ. Untranslated entries fall back to French.

Credits

The icons in src/assets/icons/ are Phosphor Icons by Phosphor, used under the MIT license. floppy-disk.svg and link-simple.svg mark the project cards.