diff --git a/.gitignore b/.gitignore index c5bc47a..0dd50d0 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ @@ -62,6 +61,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +# lib/ # PyInstaller # Usually these files are written by a python script from a template diff --git a/content.yml b/content.yml index 0cd575d..4c29388 100644 --- a/content.yml +++ b/content.yml @@ -62,6 +62,23 @@ experience: en: "Implementation of a 'scoping review': Comprehensive source research to the extent of 2000 candidates" - de: Editorielle Vorbereitung eines Arbeitspapiers auf eine Veröffentlichung durch wissenschaftlichen Verlag en: Editorial adaptation from a working paper towards a journal article ready for publishing + - date: + de: 2023--2024 + en: 2023--2024 + typeid: 2 + title: + de: Eventlogistik, Eventverwaltung und Logistikkoordination + en: Event logistics, event management and logistics coordination + place: + de: Belantis & EmiR Entertainment + en: Belantis & EmiR Entertainment + bullets: + - de: Betreuung des Aufbaus von Publikumsevents zwischen 100 und 1000 Gästen + en: Event setup for public events accommodating between 100 and 1000 guests + - de: Durchführung von Logistikarbeiten zur Vorbereitung der Eventflächen + en: Execution of logistics tasks in preparation for the respective event areas + - de: Verlagerung und Platzierung von Dekorations- und funktionellen Elementen an verschiedenen Standorten + en: Relocation and placement of decorative and functinoal elements at various locations - date: de: 2023--2024 en: 2023--2024 @@ -143,7 +160,7 @@ experience: typeid: 1 title: de: Redaktionsarbeit, Soziale Absicherung und Widerstandsfähigkeit - en: Editorial work, Social Protection and Resilience, Roskilde University + en: Editorial work, Social Protection and Resilience place: de: Universität Roskilde en: Roskilde University @@ -355,7 +372,7 @@ education: en: HTWK Leipzig, Germany title: de: Medieninformatik, BSc (nicht abg.) - en: Media Computer Science, BSc (not compl.) + en: Media Computer Science, BSc (incompl.) date: de: 2015 en: 2015 diff --git a/cv.typ b/cv.typ index 19a5459..c1f6971 100644 --- a/cv.typ +++ b/cv.typ @@ -1,157 +1,7 @@ -#import "lib.typ": * +#import "lib/resume.typ": resume -#let cv(contents, use_sidebar: false) = { - show: style - show: smartypants - 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) - - let body = { - if "summary" in contents { - section( - title: "", - { - contents.summary.at(lang) - }, - ) - } - - if "experience" in contents { - let title = (en: "Professional Experience", de: "Berufserfahrung").at(lang) - section(title: title, entries: contents.experience)[] - } - - if "education" in contents { - let title = (en: "Education", de: "Ausbildung").at(lang) - section(title: title, entries: contents.thesis + contents.education)[] - } - - if not use_sidebar { - if "volunteering" in contents { - let title = (en: "Volunteer Work", de: "Ehrenamt").at(lang) - section(title: title, entries: contents.volunteering)[] - } - - if "skills" in contents { - let title = (en: "Qualifications", de: "Qualifikationen").at(lang) - section( - title: title, - { - sidebar_entry(item: contents.skills) - }, - ) - } - - if "languages" in contents { - let title = (en: "Languages", de: "Sprachen").at(lang) - section( - title: title, - { - sidebar_entry(item: contents.languages) - }, - ) - } - } - } - - let sidebar = { - if "volunteering" in contents { - let title = (en: "Volunteer Work", de: "Ehrenamt").at(lang) - [== #title] - for e in contents.volunteering { - [ - - *#e.title.at(lang)* (#e.date.at(lang)) - #par(e.bullets.at(0).at(lang)) \ - ] - } - } - - if "languages" in contents { - let title = (en: "Languages", de: "Sprachen").at(lang) - [== #title] - sidebar_entry(item: contents.languages, is_sidebar: true) - [\ ] - } - - if "skills" in contents { - let title = (en: "Qualifications", de: "Kenntnisse").at(lang) - [== #title] - sidebar_entry(item: contents.skills, is_sidebar: true) - } - } - - if not use_sidebar { - body - return - } - let margin = 1pt - grid( - columns: (2fr, 1fr), - block( - outset: 0pt, - inset: (top: 0.4 * margin, right: 0pt, rest: margin), - stroke: none, - width: 100%, - { - 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), - ), - ) - }) - body - }, - ), - { - v(20pt) - set block(inset: (left: 20 * margin, right: 20 * margin)) - show heading: it => align(right, upper(it)) - set list(marker: "") - show list: it => { - set par(justify: false) - align(right, block(it)) - } - sidebar - }, - ) -} - -#cv.with(use_sidebar: false)(yaml("content.yml")) +#resume.with( + main: ("experience", "education", "volunteering", "skills", "languages"), + sidebar:() +)(yaml("content.yml")) diff --git a/lib.typ b/lib/lib.typ similarity index 100% rename from lib.typ rename to lib/lib.typ diff --git a/lib/resume.typ b/lib/resume.typ new file mode 100644 index 0000000..e01dcf2 --- /dev/null +++ b/lib/resume.typ @@ -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%), + ) +} + diff --git a/lib/wrapit.typ b/lib/wrapit.typ new file mode 100644 index 0000000..12114c0 --- /dev/null +++ b/lib/wrapit.typ @@ -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 + ] + ] + }) +} diff --git a/resume.typ b/resume.typ index 2930ed1..f94862c 100644 --- a/resume.typ +++ b/resume.typ @@ -1,170 +1,3 @@ -#import "lib.typ": * +#import "lib/resume.typ": resume -#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) - - let body = { - 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 sidebar = { - 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 margin = 1pt - grid( - columns: (2fr, 1fr), - block( - outset: 0pt, - inset: (top: 0.4 * margin, right: 0pt, rest: margin), - stroke: none, - width: 100%, - { - 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), - ), - ) - }) - body - }, - ), - align( - right, - block( - width: 90%, - { - v(15pt) - set block(inset: (left: 5 * margin, right: 5 * margin)) - show heading: it => align(right, upper(it)) - set list(marker: "") - show list: it => { - set par(justify: false) - align(right, block(it)) - } - sidebar - v(15pt) - }, - ), - ), - ) -} - -#resume(yaml("content.yml")) +#resume.with()(yaml("content.yml"))