Files
rendercv/rendercv-typst/lib.typ
Sina Atalay 4e07fa2380 Add centered section title types and bump rendercv-typst to 0.3.0
Add four new section title styles: `centered_without_line`,
`centered_with_partial_line` (baseline), `centered_with_centered_partial_line`
(middle-aligned), and `centered_with_full_line`. These allow section headings
to be centered with various line decorations, complementing the existing
left-aligned options.
2026-03-20 05:20:10 +03:00

765 lines
27 KiB
Typst

#import "@preview/fontawesome:0.6.0": fa-icon
// State to hold rendercv configuration for use by components
#let rendercv-config = state("rendercv-config", (:))
// Direction-aware inset: maps logical start/end to physical left/right.
// Must be called from within a `context` scope.
#let directional-inset(start: 0cm, end: 0cm) = {
let is-rtl = rendercv-config.get().at("is-rtl")
if is-rtl {
(left: end, right: start)
} else {
(left: start, right: end)
}
}
#let headline(headline) = {
metadata("skip-content-area")
context {
let config = rendercv-config.get()
let typography-font-size-headline = config.at("typography-font-size-headline")
let typography-font-family-headline = config.at("typography-font-family-headline")
let typography-bold-headline = config.at("typography-bold-headline")
let colors-headline = config.at("colors-headline")
let typography-small-caps-headline = config.at("typography-small-caps-headline")
let header-alignment = config.at("header-alignment")
let header-space-below-headline = config.at("header-space-below-headline")
set text(
fill: colors-headline,
font: typography-font-family-headline,
size: typography-font-size-headline,
weight: if typography-bold-headline { 700 } else { 400 },
)
set align(header-alignment)
block(
if typography-small-caps-headline { smallcaps(headline) } else { headline },
width: 100%,
height: auto,
)
v(header-space-below-headline, weak: true)
}
}
#let connections(..connections) = {
metadata("skip-content-area")
context {
let config = rendercv-config.get()
let typography-line-spacing = config.at("typography-line-spacing")
let header-connections-space-between-connections = config.at("header-connections-space-between-connections")
let header-connections-separator = config.at("header-connections-separator")
let page-left-margin = config.at("page-left-margin")
let page-right-margin = config.at("page-right-margin")
let header-space-below-connections = config.at("header-space-below-connections")
let section-titles-space-above = config.at("section-titles-space-above")
let colors-connections = config.at("colors-connections")
let typography-font-family-connections = config.at("typography-font-family-connections")
let typography-font-size-connections = config.at("typography-font-size-connections")
let typography-small-caps-connections = config.at("typography-small-caps-connections")
let typography-bold-connections = config.at("typography-bold-connections")
let header-alignment = config.at("header-alignment")
set par(spacing: 0pt, leading: typography-line-spacing * 1.7, justify: false)
set text(
fill: colors-connections,
font: typography-font-family-connections,
size: typography-font-size-connections,
weight: if typography-bold-connections { 700 } else { 400 },
)
let separator = (
h(header-connections-space-between-connections / 2, weak: true)
+ header-connections-separator
+ h(header-connections-space-between-connections / 2, weak: true)
)
let separator-width = (
measure(header-connections-separator).width + header-connections-space-between-connections
)
if connections.pos().len() > 0 {
set align(header-alignment)
box(
{
layout(size => {
let line-width = 0cm
for (i, connection) in connections.pos().enumerate() {
let connection-body = if typography-small-caps-connections { smallcaps(connection) } else { connection }
let connection-width = measure(connection-body).width
let is-last = i == connections.pos().len() - 1
// Check if adding this connection + separator would exceed the line
if (
line-width + connection-width + separator-width > size.width and line-width > 0cm
) {
linebreak()
line-width = 0cm
}
// Add separator only if we're not at the start of a line
if line-width > 0cm {
separator
}
box(connection-body, width: auto)
line-width = line-width + connection-width + (if line-width > 0cm { separator-width } else { 0cm })
}
})
},
width: 100%,
height: auto,
)
}
v(header-space-below-connections - section-titles-space-above)
}
}
#let original-link = link
#let link(dest, body, icon: none, if-underline: none, if-color: none) = context {
let config = rendercv-config.get()
let links-underline = config.at("links-underline")
let links-show-external-link-icon = config.at("links-show-external-link-icon")
let typography-font-size-body = config.at("typography-font-size-body")
let colors-links = config.at("colors-links")
let icon = icon
if icon == none {
if links-show-external-link-icon {
icon = true
} else {
icon = false
}
}
let if-underline = if-underline
if if-underline == none {
if links-underline {
if-underline = true
} else {
if-underline = false
}
}
let if-color = if-color
if if-color == none {
if-color = true
}
let body = [#if if-underline [#underline(body)] else [#body]]
if icon {
let ext-icon-content = fa-icon("external-link", size: 0.65em)
let icon-width = measure(ext-icon-content).width
// Zero-height box reserves horizontal space but doesn't affect line height
// or baseline. place() renders the icon out of flow, positioned above the
// baseline to align visually with text.
let ext-icon = box(
width: icon-width,
height: 0pt,
baseline: 0pt,
place(dy: -0.75em, ext-icon-content),
)
// Wrap body in box to create BiDi isolation — the box becomes a neutral
// atomic inline, so the paragraph direction controls the icon's placement.
body = [#box[#body]#h(typography-font-size-body / 4)#ext-icon]
}
body = [#if if-color [#set text(fill: colors-links);#body] else [#body]]
original-link(dest, body)
}
// Box around body creates BiDi isolation — the icon and body are both neutral
// atomic inlines, so the paragraph direction controls their relative order.
#let connection-with-icon(icon-name, body) = [
#fa-icon(icon-name, size: 0.9em) #h(0.05cm) #box[#body]
]
#let content-area(content) = context {
let config = rendercv-config.get()
let entries-side-space = config.at("entries-side-space")
let entries-date-and-location-width = config.at("entries-date-and-location-width")
let entries-space-between-columns = config.at("entries-space-between-columns")
let entries-allow-page-break = config.at("entries-allow-page-break")
let sections-space-between-text-based-entries = config.at("sections-space-between-text-based-entries")
let section-titles-type = config.at("section-titles-type")
let typography-line-spacing = config.at("typography-line-spacing")
let justify = config.at("justify")
let entries-highlights-bullet = config.at("entries-highlights-bullet")
let entries-highlights-nested-bullet = config.at("entries-highlights-nested-bullet")
let entries-highlights-space-between-bullet-and-text = config.at(
"entries-highlights-space-between-bullet-and-text",
)
let start-align = config.at("start-align")
let start-space = entries-side-space
if section-titles-type == "moderncv" {
start-space = (
start-space + entries-date-and-location-width + entries-space-between-columns
)
}
set par(
spacing: sections-space-between-text-based-entries + typography-line-spacing,
leading: typography-line-spacing,
justify: justify,
)
set align(start-align)
set enum(
spacing: sections-space-between-text-based-entries + typography-line-spacing,
)
set list(
marker: (entries-highlights-bullet, entries-highlights-nested-bullet),
indent: 0cm,
spacing: sections-space-between-text-based-entries + typography-line-spacing,
body-indent: entries-highlights-space-between-bullet-and-text,
)
block(
content,
breakable: entries-allow-page-break,
below: sections-space-between-text-based-entries + typography-line-spacing,
inset: directional-inset(start: start-space, end: entries-side-space),
width: 100%,
)
}
#let summary(summary) = {
context {
let config = rendercv-config.get()
let entries-summary-space-left = config.at("entries-summary-space-left")
let entries-summary-space-above = config.at("entries-summary-space-above")
let typography-line-spacing = config.at("typography-line-spacing")
block(
summary,
inset: directional-inset(start: entries-summary-space-left),
above: entries-summary-space-above + typography-line-spacing,
)
}
}
#let regular-entry(main-column, date-and-location-column, main-column-second-row: none) = {
metadata("skip-content-area")
context {
let config = rendercv-config.get()
let section-titles-type = config.at("section-titles-type")
let entries-date-and-location-width = config.at("entries-date-and-location-width")
let entries-space-between-columns = config.at("entries-space-between-columns")
let entries-highlights-bullet = config.at("entries-highlights-bullet")
let entries-highlights-nested-bullet = config.at("entries-highlights-nested-bullet")
let entries-highlights-space-between-items = config.at(
"entries-highlights-space-between-items",
)
let entries-highlights-space-between-bullet-and-text = config.at(
"entries-highlights-space-between-bullet-and-text",
)
let entries-highlights-space-above = config.at("entries-highlights-space-above")
let typography-line-spacing = config.at("typography-line-spacing")
let entries-allow-page-break = config.at("entries-allow-page-break")
let sections-space-between-regular-entries = config.at("sections-space-between-regular-entries")
let entries-side-space = config.at("entries-side-space")
let justify = config.at("justify")
let typography-date-and-location-column-alignment = config.at("typography-date-and-location-column-alignment")
let entries-highlights-space-left = config.at("entries-highlights-space-left")
let start-align = config.at("start-align")
set list(
marker: (entries-highlights-bullet, entries-highlights-nested-bullet),
indent: entries-highlights-space-left,
spacing: entries-highlights-space-between-items + typography-line-spacing,
body-indent: entries-highlights-space-between-bullet-and-text,
)
let list-depth = state("list-depth", 0)
show list.item: i => {
list-depth.update(d => d + 1)
i
list-depth.update(d => d - 1)
}
show list: l => {
context if list-depth.get() == 1 {
v(entries-highlights-space-above)
}
context if list-depth.get() == 2 {
v(entries-highlights-space-between-items)
}
l
}
set par(
spacing: typography-line-spacing,
leading: typography-line-spacing,
justify: justify,
)
block(
{
if section-titles-type == "moderncv" {
grid(
columns: (entries-date-and-location-width, 1fr),
column-gutter: entries-space-between-columns,
align: (typography-date-and-location-column-alignment, start-align),
[
#date-and-location-column
],
[
#main-column
#main-column-second-row
],
)
} else {
if repr(main-column) != "[ ]" or repr(date-and-location-column) != "[ ]" {
grid(
columns: (1fr, entries-date-and-location-width),
column-gutter: entries-space-between-columns,
align: (start-align, typography-date-and-location-column-alignment),
main-column, date-and-location-column,
)
}
set align(start-align)
main-column-second-row
}
},
breakable: entries-allow-page-break,
below: sections-space-between-regular-entries + typography-line-spacing,
inset: (
left: entries-side-space,
right: entries-side-space,
),
width: 100%,
)
}
}
#let education-entry(main-column, date-and-location-column, degree-column: none, main-column-second-row: none) = {
metadata("skip-content-area")
context {
let config = rendercv-config.get()
let entries-space-between-columns = config.at("entries-space-between-columns")
let entries-degree-width = config.at("entries-degree-width")
let start-align = config.at("start-align")
regular-entry(
if degree-column != none {
grid(
columns: (entries-degree-width, 1fr),
column-gutter: entries-space-between-columns,
align: (start-align, auto),
[
#degree-column
],
[
#main-column
],
)
} else {
main-column
},
date-and-location-column,
main-column-second-row: if main-column-second-row != none and repr(main-column-second-row) != "[ ]" {
[
#block(
main-column-second-row,
inset: directional-inset(
start: if degree-column != none { entries-degree-width + entries-space-between-columns } else { 0cm },
),
)
]
} else { none },
)
}
}
#let reversed-numbered-entries(entries) = {
set enum(reversed: true)
entries
}
#let rendercv(
doc,
name: "John Doe",
title: "John Doe's CV",
footer: context { "Page " + str(here().page()) + " of " + str(counter(page).final().first()) + "" },
top-note: "Last updated in " + datetime.today().display(),
locale-catalog-language: "en",
text-direction: ltr,
page-size: "us-letter",
page-top-margin: 0.7in,
page-bottom-margin: 0.7in,
page-left-margin: 0.7in,
page-right-margin: 0.7in,
page-show-footer: true,
page-show-top-note: true,
colors-body: rgb(0, 0, 0),
colors-name: rgb(0, 79, 144),
colors-headline: rgb(0, 79, 144),
colors-connections: rgb(0, 79, 144),
colors-section-titles: rgb(0, 79, 144),
colors-links: rgb(0, 79, 144),
colors-footer: rgb(128, 128, 128),
colors-top-note: rgb(128, 128, 128),
typography-line-spacing: 0.6em,
typography-alignment: "justified",
typography-date-and-location-column-alignment: right,
typography-font-family-body: "Raleway",
typography-font-family-name: "Raleway",
typography-font-family-headline: "Raleway",
typography-font-family-connections: "Raleway",
typography-font-family-section-titles: "Raleway",
typography-font-size-body: 10pt,
typography-font-size-name: 30pt,
typography-font-size-headline: 10pt,
typography-font-size-connections: 10pt,
typography-font-size-section-titles: 1.4em,
typography-small-caps-name: false,
typography-small-caps-headline: false,
typography-small-caps-connections: false,
typography-small-caps-section-titles: false,
typography-bold-name: false,
typography-bold-headline: false,
typography-bold-connections: false,
typography-bold-section-titles: false,
links-underline: false,
links-show-external-link-icon: false,
header-alignment: left,
header-photo-width: 3.5cm,
header-space-below-name: 0.7cm,
header-space-below-headline: 0.7cm,
header-space-below-connections: 0.7cm,
header-connections-hyperlink: true,
header-connections-show-icons: true,
header-connections-display-urls-instead-of-usernames: false,
header-connections-separator: "",
header-connections-space-between-connections: 0.5cm,
section-titles-type: "with_full_line",
section-titles-line-thickness: 0.5pt,
section-titles-space-above: 0.5cm,
section-titles-space-below: 0.3cm,
sections-allow-page-break: true,
sections-space-between-text-based-entries: 0.3em,
sections-space-between-regular-entries: 1.2em,
entries-date-and-location-width: 4.15cm,
entries-side-space: 0.2cm,
entries-space-between-columns: 0.1cm,
entries-allow-page-break: false,
entries-short-second-row: false,
entries-degree-width: 1cm,
entries-summary-space-left: 0cm,
entries-summary-space-above: 0.12cm,
entries-highlights-bullet: "•",
entries-highlights-nested-bullet: "•",
entries-highlights-space-left: 0cm,
entries-highlights-space-above: 0.12cm,
entries-highlights-space-between-items: 0.12cm,
entries-highlights-space-between-bullet-and-text: 0.5em,
date: datetime(
year: 2025,
month: 12,
day: 5,
),
) = [
#let (justify, hyphenate) = (
"justified": (true, true),
"left": (false, false),
"justified-with-no-hyphenation": (true, false),
).at(typography-alignment)
#let is-rtl = text-direction == rtl
#let start-align = if is-rtl { right } else { left }
#let end-align = if is-rtl { left } else { right }
// Initialize state with all configuration parameters
#rendercv-config.update((
// Direction
text-direction: text-direction,
is-rtl: is-rtl,
start-align: start-align,
// Page
page-left-margin: page-left-margin,
page-right-margin: page-right-margin,
// Colors
colors-body: colors-body,
colors-name: colors-name,
colors-headline: colors-headline,
colors-connections: colors-connections,
colors-section-titles: colors-section-titles,
colors-links: colors-links,
colors-footer: colors-footer,
colors-top-note: colors-top-note,
// Typography
typography-line-spacing: typography-line-spacing,
typography-alignment: typography-alignment,
typography-date-and-location-column-alignment: typography-date-and-location-column-alignment,
typography-font-family-body: typography-font-family-body,
typography-font-family-name: typography-font-family-name,
typography-font-family-headline: typography-font-family-headline,
typography-font-family-connections: typography-font-family-connections,
typography-font-family-section-titles: typography-font-family-section-titles,
typography-font-size-body: typography-font-size-body,
typography-font-size-name: typography-font-size-name,
typography-font-size-headline: typography-font-size-headline,
typography-font-size-connections: typography-font-size-connections,
typography-font-size-section-titles: typography-font-size-section-titles,
typography-small-caps-name: typography-small-caps-name,
typography-small-caps-headline: typography-small-caps-headline,
typography-small-caps-connections: typography-small-caps-connections,
typography-small-caps-section-titles: typography-small-caps-section-titles,
typography-bold-name: typography-bold-name,
typography-bold-headline: typography-bold-headline,
typography-bold-connections: typography-bold-connections,
typography-bold-section-titles: typography-bold-section-titles,
// Links
links-underline: links-underline,
links-show-external-link-icon: links-show-external-link-icon,
// Header
header-alignment: header-alignment,
header-photo-width: header-photo-width,
header-space-below-name: header-space-below-name,
header-space-below-headline: header-space-below-headline,
header-space-below-connections: header-space-below-connections,
header-connections-hyperlink: header-connections-hyperlink,
header-connections-show-icons: header-connections-show-icons,
header-connections-display-urls-instead-of-usernames: header-connections-display-urls-instead-of-usernames,
header-connections-separator: header-connections-separator,
header-connections-space-between-connections: header-connections-space-between-connections,
// Section titles
section-titles-type: section-titles-type,
section-titles-line-thickness: section-titles-line-thickness,
section-titles-space-above: section-titles-space-above,
section-titles-space-below: section-titles-space-below,
// Sections
sections-allow-page-break: sections-allow-page-break,
sections-space-between-regular-entries: sections-space-between-regular-entries,
sections-space-between-text-based-entries: sections-space-between-text-based-entries,
// Entries
entries-date-and-location-width: entries-date-and-location-width,
entries-side-space: entries-side-space,
entries-space-between-columns: entries-space-between-columns,
entries-allow-page-break: entries-allow-page-break,
entries-summary-space-left: entries-summary-space-left,
entries-summary-space-above: entries-summary-space-above,
entries-highlights-bullet: entries-highlights-bullet,
entries-highlights-nested-bullet: entries-highlights-nested-bullet,
entries-highlights-space-left: entries-highlights-space-left,
entries-highlights-space-above: entries-highlights-space-above,
entries-highlights-space-between-items: entries-highlights-space-between-items,
entries-highlights-space-between-bullet-and-text: entries-highlights-space-between-bullet-and-text,
entries-degree-width: entries-degree-width,
// Internal computed values
justify: justify,
))
// Metadata:
#set document(author: name, title: title, date: date)
// Page:
#set page(
margin: (
top: page-top-margin,
bottom: page-bottom-margin,
left: page-left-margin,
right: page-right-margin,
),
paper: page-size,
footer: if page-show-footer and footer != none and footer != "" {
text(
fill: colors-footer,
align(center, [#footer]),
size: 0.9em,
)
} else {
none
},
footer-descent: 0% - 0.6em + page-bottom-margin / 2,
)
// Text:
#set text(
font: typography-font-family-body,
size: typography-font-size-body,
lang: locale-catalog-language,
dir: text-direction,
hyphenate: hyphenate,
fill: colors-body,
// Disable ligatures for better ATS compatibility:
ligatures: true,
)
// Main heading (name):
#show heading.where(level: 1): it => [
#set par(spacing: 0pt)
#set align(header-alignment)
#set text(
font: typography-font-family-name,
size: typography-font-size-name,
fill: colors-name,
weight: if typography-bold-name { 700 } else { 400 },
)
#let body
#if typography-small-caps-name {
body = [#smallcaps(it.body)]
} else {
body = [#it.body]
}
#body
// Vertical space after the name
#v(header-space-below-name, weak: true)
]
// Section titles:
#show heading.where(level: 2): it => [
#let is-centered = section-titles-type in ("centered_without_line", "centered_with_partial_line", "centered_with_centered_partial_line", "centered_with_full_line")
#set align(if is-centered { center } else { start-align })
#set text(size: (1em / 1.2)) // reset
#set text(
font: typography-font-family-section-titles,
size: (typography-font-size-section-titles),
weight: if typography-bold-section-titles { 700 } else { 400 },
fill: colors-section-titles,
)
#let section-title = (
if typography-small-caps-section-titles [
#smallcaps(it.body)
] else [
#it.body
]
)
// Vertical space above the section title
#v(section-titles-space-above, weak: true)
#block(
breakable: false,
width: 100%,
[
#if section-titles-type == "moderncv" [
#grid(
columns: (entries-date-and-location-width + entries-side-space, 1fr),
column-gutter: entries-space-between-columns,
align: (end-align, start-align),
[
#align(horizon, box(
width: 1fr,
height: section-titles-line-thickness,
fill: colors-section-titles,
))
],
[
#section-title
],
)
] else if section-titles-type == "centered_without_line" [
#section-title
] else if section-titles-type == "centered_with_partial_line" [
#grid(
columns: (1fr, auto, 1fr),
align: (bottom, center + bottom, bottom),
column-gutter: 0.3em,
[#box(width: 1fr, height: section-titles-line-thickness, fill: colors-section-titles)],
[#section-title],
[#box(width: 1fr, height: section-titles-line-thickness, fill: colors-section-titles)],
)
] else if section-titles-type == "centered_with_centered_partial_line" [
#grid(
columns: (1fr, auto, 1fr),
align: (horizon, center, horizon),
column-gutter: 0.3em,
[#box(width: 1fr, height: section-titles-line-thickness, fill: colors-section-titles)],
[#section-title],
[#box(width: 1fr, height: section-titles-line-thickness, fill: colors-section-titles)],
)
] else if section-titles-type == "centered_with_full_line" [
#section-title
#v(typography-font-size-body * 0.3)
#box(width: 1fr, height: section-titles-line-thickness, fill: colors-section-titles)
] else [
#section-title
#if section-titles-type == "with_partial_line" [
#box(width: 1fr, height: section-titles-line-thickness, fill: colors-section-titles)
] else if section-titles-type == "with_full_line" [
#v(typography-font-size-body * 0.3)
#box(width: 1fr, height: section-titles-line-thickness, fill: colors-section-titles)
]
]
],
)
// Vertical space after the section title
#v(section-titles-space-below - 0.5em)
]
// Top note:
#if page-show-top-note and top-note != none and top-note != "" {
let dx = if is-rtl { entries-side-space } else { -entries-side-space }
place(
top + end-align,
dy: -page-top-margin / 2,
dx: dx,
text(
[#top-note],
fill: colors-top-note,
size: 0.9em,
),
)
}
// From: https://github.com/typst/typst/issues/2666
#let group-sections(content) = {
let sections = ()
let preamble = () // Content before any heading
for element in content.children {
if element.func() == heading {
// Push a new section
sections.push((element, ()))
} else if sections.len() > 0 {
// Add to current section
sections.last().at(1).push(element)
} else {
// No heading yet - this is preamble content
preamble.push(element)
}
}
// Join the content in each section
let result = sections.map(it => {
let (title, content) = it
(title, content.join())
})
// Return both preamble and sections
(preamble: preamble.join(), sections: result)
}
#set par(spacing: 0cm)
#let grouped = group-sections(doc)
// Render preamble content (before any heading)
#if grouped.preamble != none and grouped.preamble.func() != parbreak [
#block(breakable: sections-allow-page-break)[
#grouped.preamble
]
]
// Render sections as before
#for (section-title, section-content) in grouped.sections [
#section-title
#let should-skip = {
let skip = false
if section-content != none and section-content.has("children") {
for child in section-content.children {
if child.func() == metadata and child.value == "skip-content-area" {
skip = true
break
}
}
}
skip
}
#if section-content != none and section-content.func() != parbreak [
#block(breakable: sections-allow-page-break)[
#if should-skip [
#section-content
] else [
#content-area(section-content)
]
]
]
]
]