diff --git a/resume.typ b/resume.typ index dcc4ca9..4c9f55c 100644 --- a/resume.typ +++ b/resume.typ @@ -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%), ) } diff --git a/wrapit.typ b/wrapit.typ new file mode 100644 index 0000000..12114c0 --- /dev/null +++ b/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 + ] + ] + }) +}