ref(repo): Refactor resume into single importable function

Moved all 'behind-the-scenes' structure to 'lib/' folder and made resume
importable as the main utility.
This commit is contained in:
Marty Oehme 2025-03-18 17:00:01 +01:00
parent eace89411b
commit 21d2010762
Signed by: Marty
GPG key ID: 4E535BC19C61886E
6 changed files with 171 additions and 318 deletions

182
lib/lib.typ Normal file
View file

@ -0,0 +1,182 @@
// transform md-similes to actual symbols
#let smartypants(it) = {
// smartypants and latex compatibility
show "--": [#sym.dash.en]
show "---": [#sym.dash.em]
show "\&": [#sym.amp]
it
}
// set some styles
#let style(it) = {
show heading: set text(font: "New Computer Modern")
show link: underline
show: smartypants
it
}
// Choose the compiled language through cli by doing
//
// $ typst compile --input lang=de cv.typ
//
#let lang = {
if "lang" in sys.inputs and sys.inputs.lang == "de" {
"de"
} else {
"en"
}
}
#let _columns_3(left_body, center_body, right_body) = {
block[
#box(width: 1fr)[
#align(left)[#left_body]
]
#box(width: 1fr)[
#align(center)[#center_body]
]
#box(width: 1fr)[
#align(right)[#right_body]
]
]
}
#let header(about, columns: (1.5fr, 1fr, 1fr)) = {
[= #about.fullname]
let contact_fields = (
for c in about.contact {
if "link" in c {
([#c.icon ~ #link(c.link)[#c.text]],)
} else {
([#c.icon ~ #c.text],)
}
}
)
grid(
columns: columns,
gutter: 5pt,
..contact_fields
)
}
#let subdued(body) = {
block(inset: 5%, width: 85%, text(fill: luma(150), body))
}
#let entry(item: ()) = {
if "title" in item {
[*#item.title.at(lang)*]
}
if "place" in item {
if "title" in item {
[, ]
}
[_#item.place.at(lang)_]
}
[#h(1fr)]
if "date" in item {
[ _#item.date.at(lang)_ \ ]
}
if "bullets" in item {
for bullet in item.bullets {
[- #bullet.at(lang)]
}
}
if "publication" in item {
subdued[#item.publication.at(lang) \ ]
}
if "abstract" in item {
subdued[#item.abstract.at(lang) \ ]
}
}
#let sidebar_entry(item: (), is_sidebar: false) = {
let side_list(body) = if is_sidebar { list(body) } else { par(body) }
for skill in item {
side_list({
[*#skill.name.at(lang)*]
if is_sidebar [\ ] else [ (]
for (i, v) in skill.items.enumerate() {
[#v.at(lang)]
if i < skill.items.len() - 1 {
[, ]
}
}
if not is_sidebar [)]
})
}
}
#let horizon_line() = {
v(-3pt)
line(length: 100%)
v(-5pt)
}
#let section_header(title) = {
[== #title]
horizon_line()
};
#let section(title: "Section", entries: (), body) = {
section_header(title)
if body == none or body == [] {
for e in entries {
entry(item: e)
}
} else {
body
}
};
// TODO: make it _return_ the data, not display it on its own
#let by_client(experience: ()) = {
let by_client = (:)
for item in experience {
let client = item.place.at(lang)
if client not in by_client {
by_client.insert(client, ())
}
by_client.at(client).push((title: item.title.at(lang), date: item.date.at(lang)))
}
for (client, jobs) in by_client {
[*#client*:]
for j in jobs {
[- #j.title #h(1fr) #j.date]
}
}
}
#let by_experience_type(type: (), experience: ()) = {
let by_ty = (:)
for (id, desc) in type {
let matching_exp_items = experience.filter(item => int(item.typeid) == int(id))
if matching_exp_items.len() == 0 {
return
}
[=== _#desc.at(lang)_]
by_client(experience: matching_exp_items)
}
}
// Slightly re-styled entry with PLACE first and TITLE second
#let education_entry(item: ()) = {
assert(
"place" in item and "title" in item and "date" in item,
message: "Education items require place, program and date.",
)
[*#item.place.at(lang)*, #item.title.at(lang) #h(1fr)]
[ _#item.date.at(lang)_ \ ]
}
// Restyled entry with PLACE not emphasized like usual, and no date but an abstract
#let thesis_entry(item: ()) = {
assert("title" in item and "place" in item, message: "Thesis items require type and title.")
[*#item.title.at(lang)* #item.place.at(lang) #h(1fr)]
[#par(item.abstract.at(lang))]
}

162
lib/resume.typ Normal file
View file

@ -0,0 +1,162 @@
#import "lib.typ": *
#import "wrapit.typ": *
#let create_body(main: (), contents: (:)) = {
for item in main {
if item == "summary" and "summary" in contents {
section(
title: "",
{
contents.summary.at(lang)
},
)
}
if item == "experience_by_type" and "experience" in contents {
let title = (en: "Professional Experience", de: "Berufserfahrung").at(lang)
section(title: title)[]
by_experience_type(experience: contents.experience, type: contents.experience_types)
}
if item == "experience_by_client" and "experience" in contents {
let title = (en: "Professional Experience", de: "Berufserfahrung").at(lang)
section(title: title)[]
by_client(experience: contents.experience)
}
if item == "experience" and "experience" in contents {
let title = (en: "Professional Experience", de: "Berufserfahrung").at(lang)
section(title: title, entries: contents.experience)[]
}
if item == "education" and "education" in contents {
let title = (en: "Education", de: "Ausbildung").at(lang)
section(title: title, entries: contents.thesis + contents.education)[]
}
if item == "volunteering" and "volunteering" in contents {
let title = (en: "Volunteer Work", de: "Ehrenamt").at(lang)
section(title: title, entries: contents.volunteering)[]
}
if item == "skills" and "skills" in contents {
let title = (en: "Qualifications", de: "Qualifikationen").at(lang)
section(
title: title,
{
sidebar_entry(item: contents.skills)
},
)
}
if item == "languages" and "languages" in contents {
let title = (en: "Languages", de: "Sprachen").at(lang)
section(
title: title,
{
sidebar_entry(item: contents.languages)
},
)
}
}
}
#let create_sidebar(sidebar: (), contents: (:)) = {
for item in sidebar {
if item == "volunteering" and "volunteering" in contents {
let title = (en: "Volunteer Work", de: "Ehrenamt").at(lang)
[== #title]
for e in contents.at("volunteering") {
[
- *#e.title.at(lang)* (#e.date.at(lang))
#par(e.bullets.at(0).at(lang)) \
]
}
}
if item == "languages" and "languages" in contents {
let title = (en: "Languages", de: "Sprachen").at(lang)
[== #title]
sidebar_entry(item: contents.languages, is_sidebar: true)
[\ ]
}
if item == "skills" and "skills" in contents {
let title = (en: "Qualifications", de: "Kenntnisse").at(lang)
[== #title]
sidebar_entry(item: contents.skills, is_sidebar: true)
}
}
}
#let resume(contents, main: ("experience_by_type", "education"), sidebar: ("volunteering", "languages", "skills")) = {
show: style
set text(lang: lang)
let date_formatting = {
if lang == "de" {
"[day]. [month repr:long] [year]"
} else {
"[month repr:long] [day], [year]"
}
}
set page(
paper: "a4",
margin: (x: 0.9cm, y: 1.3cm),
footer: [
#set text(
fill: luma(200),
size: 8pt,
)
#_columns_3[
#smallcaps[#datetime.today().display(date_formatting)]
][
#smallcaps[#contents.about.fullname]
][
#context counter(page).display()
]
],
)
set par(justify: true)
header(contents.about)
set block(above: 10pt)
show heading.where(level: 1): it => style(s => {
let h = text(size: 18pt, upper(it))
let dim = measure(h, s)
stack(
dir: ltr,
h,
place(
dy: 7pt,
dx: 10pt,
horizon + left,
line(stroke: accent-color, length: 100% - dim.width - 10pt),
),
)
})
let margin = 1pt
let sb = if sidebar.len() > 0 {
block(
fill: luma(230),
inset: (top: 15 * margin, left: 10 * margin, right: 15 * margin, bottom: 15 * margin),
{
show heading: it => align(right, upper(it))
set list(marker: "")
show list: it => {
set par(justify: false)
align(right, block(it))
}
create_sidebar(sidebar: sidebar, contents: contents)
},
)
} else { [] }
wrap-content(
sb,
create_body(main: main, contents: contents),
align: top + right,
columns: (auto, 30%),
)
}

340
lib/wrapit.typ Normal file
View file

@ -0,0 +1,340 @@
// https://github.com/ntjess/wrap-it?tab=readme-ov-file
#let styled = text(red)[lorem].func()
#let _gridded(dir, fixed, to-wrap, ..kwargs) = {
let dir-kwargs = (:)
if dir not in (ltr, rtl) {
panic("Specify either `rtl` or `ltr` as the wrap direction")
}
let args = if dir == rtl {
(to-wrap, fixed)
} else {
(fixed, to-wrap)
}
grid(..args, columns: 2, rows: 2, column-gutter: 1em, ..kwargs)
}
#let _grid-height(content, container-size) = {
measure(box(width: container-size.width, content)).height
}
#let _get-chunk(words, end, reverse, start: 0) = {
if end < 0 {
return words.join(" ")
}
if reverse {
words = words.rev()
}
let subset = words.slice(start, end)
if reverse {
subset = subset.rev()
}
subset.join(" ")
}
#let _get-wrap-index(height-func, words, goal-height, reverse) = {
for index in range(1, words.len(), step: 1) {
let cur-height = height-func(_get-chunk(words, index, reverse))
if cur-height > goal-height {
return index - 1
}
}
return -1
}
#let _rewrap(element, new-content) = {
let fields = element.fields()
for key in ("body", "text", "children", "child") {
if key in fields {
let _ = fields.remove(key)
}
}
let positional = (new-content,)
if "styles" in fields {
positional.push(fields.remove("styles"))
}
element.func()(..fields, ..positional)
}
#let split-other(body, height-func, goal-height, align, splitter-func) = {
(wrapped: none, rest: body)
}
#let split-has-text(body, height-func, goal-height, align, splitter-func) = {
let words = body.text.split(" ")
let reverse = align.y == bottom
let wrap-index = _get-wrap-index(height-func, words, goal-height, reverse)
let _rewrap = _rewrap.with(body)
if wrap-index > 0 {
let chunk = _rewrap(_get-chunk(words, wrap-index, reverse))
let end-chunk = _rewrap(_get-chunk(words, words.len(), reverse, start: wrap-index))
(
wrapped: context {
chunk
linebreak(justify: par.justify)
},
rest: end-chunk,
)
} else {
(wrapped: none, rest: body)
}
}
#let split-has-children(body, height-func, goal-height, align, splitter-func) = {
let reverse = align.y == bottom
let children = if reverse {
body.children.rev()
} else {
body.children
}
for (ii, child) in children.enumerate() {
let prev-children = children.slice(0, ii).join()
let new-height-func(child) = {
height-func((prev-children, child).join())
}
let height = new-height-func(child)
if height <= goal-height {
continue
}
// height func calculator should now account for prior children
let split = splitter-func(child, new-height-func, goal-height, align)
let new-children = (..children.slice(0, ii), split.wrapped)
let new-rest = children.slice(ii + 1)
if split.rest != none {
new-rest.insert(0, split.rest)
}
if reverse {
new-children = new-children.rev()
new-rest = new-rest.rev()
}
return (
wrapped: _rewrap(body, new-children),
rest: _rewrap(body, new-rest),
)
}
panic("This function should only be called if the seq child should be split")
}
#let split-has-body(body, height-func, goal-height, align, splitter-func) = {
// Elements that can be split and have a 'body' field.
let splittable = (strong, emph, underline, stroke, overline, highlight, list.item, styled)
let new-height-func(content) = {
height-func(_rewrap(body, content))
}
let args = (new-height-func, goal-height, align, splitter-func)
let body-text = body.at("body", default: body.at("child", default: none))
if body.func() in splittable {
let result = splitter-func(body-text, new-height-func, goal-height, align)
if result.wrapped != none {
return (wrapped: _rewrap(body, result.wrapped), rest: _rewrap(body, result.rest))
} else {
return split-other(body, ..args)
}
}
// Shape doesn't split nicely, so treat it as unwrappable
return split-other(body, ..args)
}
#let splitter(body, height-func, goal-height, align) = {
let self-height = height-func(body)
if self-height <= goal-height {
return (wrapped: body, rest: none)
}
if type(body) == str {
body = text(body)
}
let body-splitter = if body.has("text") {
split-has-text
} else if body.has("body") or body.has("child") {
split-has-body
} else if body.has("children") {
split-has-children
} else {
split-other
}
return body-splitter(body, height-func, goal-height, align, splitter)
}
#let _inner-wrap-content(to-wrap, y-align, grid-func, container-size, ..grid-kwargs) = {
let height-func(txt) = _grid-height(grid-func(txt), container-size)
let goal-height = height-func([])
if y-align == top {
goal-height += measure(v(1em)).height
}
let result = splitter(to-wrap, height-func, goal-height, y-align)
if y-align == top {
grid-func(result.wrapped)
result.rest
} else {
result.rest
grid-func(result.wrapped)
}
}
/// Places `to-wrap` next to `fixed`, wrapping `to-wrap` as its height overflows `fixed`.
///
/// *Basic Use:*
/// ```typ
/// #let body = lorem(40)
/// #wrap-content(rect(fill: teal), body)
/// ```
///
/// *Something More Fun:*
/// ```typ
/// #set par(justify: true)
/// // Helpers; not required
/// #let grad(map) = {
/// gradient.linear(
/// ..eval("color.map." + map)
/// )
/// }
/// #let make-fig(fill) = {
/// set figure.caption(separator: "")
/// fill = grad(fill)
/// figure(
/// rect(fill: fill, radius: 0.5em),
/// caption: [],
/// )
/// }
/// #let (fig1, fig2) = {
/// ("viridis", "plasma").map(make-fig)
/// }
/// #wrap-content(fig1, body, align: right)
/// #wrap-content(fig2, [#body #body], align: bottom)
/// ```
///
/// Note that you can increase the distance between a figure's bottom and the wrapped
/// text by boxing it with an inset:
/// ```typ
/// #let spaced = box(
/// make-fig("rocket"),
/// inset: (bottom: 0.3em)
/// )
/// #wrap-content(spaced, body)
/// ```
///
/// - fixed (content): Content that will not be wrapped, (i.e., a figure).
///
/// - to-wrap (content): Content that will be wrapped, (i.e., text). Currently, logic
/// works best with pure-text content, but hypothetically will work with any `content`.
///
/// - align (alignment): Alignment of `fixed` relative to `to-wrap`. `top` will align
/// the top of `fixed` with the top of `to-wrap`, and `bottom` will align the bottom of
/// `fixed` with the bottom of `to-wrap`. `left` and `right` alignments determine
/// horizontal alignment of `fixed` relative to `to-wrap`. Alignments can be combined,
/// i.e., `bottom + right` will align the bottom-right corner of `fixed` with the
/// bottom-right corner of `to-wrap`.
/// ```typ
/// #wrap-content(
/// make-fig("turbo"),
/// body,
/// align: bottom + right
/// )
/// ```
///
/// - size (size, auto): Size of the wrapping container. If `auto`, this will be set to
/// the current container size. Otherwise, wrapping logic will attempt to stay within
/// the provided constraints.
///
/// - ..grid-kwargs (any): Keyword arguments to pass to the underlying `grid` function.
/// Of note:
/// - `column-gutter` controls horizontal margin between `fixed` and `to-wrap`. Or,
/// you can surround the fixed content in a box with `(inset: ...)` for more
/// fine-grained control.
/// - `columns` can be set to force sizing of `fixed` and `to-wrap`. For instance,
/// `columns: (50%, 50%)` will force `fixed` and `to-wrap` to each take up half
/// of the available space. If content isn't this big, the fill will be blank
/// margin.
/// ```typ
/// #let spaced = box(
/// make-fig("mako"), inset: 0.5em
/// )
/// #wrap-content(spaced, body)
/// ```
/// ```typ
/// #wrap-content(
/// make-fig("spectral"),
/// body,
/// align: bottom,
/// columns: (50%, 50%),
/// )
/// ```
///
#let wrap-content(
fixed,
to-wrap,
align: top + left,
size: auto,
..grid-kwargs,
) = {
if center in (align.x, align.y) {
panic("Center alignment is not supported")
}
// "none" x alignment defaults to left
let dir = if align.x == right {
rtl
} else {
ltr
}
let gridded(..args) = box(_gridded(dir, fixed, ..grid-kwargs, ..args))
// "none" y alignment defaults to top
let y-align = if align.y == bottom {
bottom
} else {
top
}
if size != auto {
_inner-wrap-content(to-wrap, y-align, gridded, size, ..grid-kwargs)
} else {
layout(container-size => {
_inner-wrap-content(to-wrap, y-align, gridded, container-size, ..grid-kwargs)
})
}
}
/// Wrap a body of text around two pieces of content. The logic only works if enough text
/// exists to overflow both the top and bottom content. Use this instead of 2 separate
/// `wrap-content` calls if you want to avoid a paragraph break between the top and bottom
/// content.
///
/// *Example:*
/// ```typ
/// #let fig1 = make-fig("inferno")
/// #let fig2 = make-fig("rainbow")
/// #wrap-top-bottom(fig1, fig2, lorem(60))
/// ```
/// - top-fixed (content): Content that will not be wrapped, (i.e., a figure).
/// - bottom-fixed (content): Content that will not be wrapped, (i.e., a figure).
/// - body (content): Content that will be wrapped, (i.e., text)
/// - top-kwargs (any): Keyword arguments to pass to the underlying `wrap-content` function
/// for the top content. `x` alignment is kept (left/right), but `y` alignment is
/// overridden to `top`.
/// - bottom-kwargs (any): Keyword arguments to pass to the underlying `wrap-content` function
/// for the bottom content. `x` alignment is kept (left/right), but `y` alignment is
/// overridden to `bottom`.
///
#let wrap-top-bottom(
top-fixed,
bottom-fixed,
body,
top-kwargs: (:),
bottom-kwargs: (:),
) = {
top-kwargs = top-kwargs + (
align: top-kwargs.at("align", default: top + left).x + top,
)
bottom-kwargs = bottom-kwargs + (
align: bottom-kwargs.at("align", default: bottom + right).x + bottom,
)
layout(size => {
let wrapfig(..args) = wrap-content(size: size, ..args)
wrapfig(top-fixed, ..top-kwargs)[
#wrapfig(bottom-fixed, ..bottom-kwargs)[
#body
]
]
})
}