feat(resume): Add wrapit library to dynamically add sidebar

Removes issue that grid will be static throughout all pages and thus
empty space where the sidebar is on page 1. Now, the sidebar aligns
nicely along the first page but then we can use the full width for the
next few pages.
This commit is contained in:
Marty Oehme 2025-03-18 14:57:57 +01:00
parent bb9606b2db
commit 1afc51a857
Signed by: Marty
GPG key ID: 4E535BC19C61886E
2 changed files with 374 additions and 43 deletions

View file

@ -1,4 +1,5 @@
#import "lib.typ": *
#import "wrapit.typ": *
#let create_body(main: (), contents: (:)) = {
for item in main {
@ -86,7 +87,7 @@
}
}
#let resume(contents, main: ( "experience", "education"), sidebar: ("volunteering", "languages", "skills")) = {
#let resume(contents, main: ("experience", "education"), sidebar: ("volunteering", "languages", "skills")) = {
show: style
set text(lang: lang)
@ -119,53 +120,43 @@
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
grid(
columns: (2fr, 1fr),
let sb = if sidebar.len() > 0 {
block(
outset: 0pt,
inset: (top: 0.4 * margin, right: 0pt, rest: margin),
stroke: none,
width: 100%,
fill: luma(230),
inset: (top: 15 * margin, left: 10 * margin, right: 15 * margin, bottom: 15 * margin),
{
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),
),
)
})
create_body(main: main, contents: contents)
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)
},
),
align(
right,
block(
fill: luma(230),
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))
}
create_sidebar(sidebar: sidebar, contents: contents)
v(15pt)
},
),
),
)
} else { [] }
wrap-content(
sb,
create_body(main: main, contents: contents),
align: top + right,
columns: (auto, 30%),
)
}

340
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
]
]
})
}