A single function for equal-height stacking
Inspired by oasis-align https://www.reddit.com/r/typst/comments/1vnrcrp/oasisalign_v040_create_cleanly_aligned_layouts/ and automosaic https://www.reddit.com/r/typst/comments/1vksmw5/automosaic_automatic_aspectpreserving_photo/ I thought it would be nice to have a single function that performs this automatic sizing in the rawest form possible.
I've written a single function, a couple hundred lines of code, that extends the built-in `stack` function with automatic sizing:
- horizontal stacking with common height, or vertical with common width,
- stacking of any number of images,
- stacking of any number of paragraphs,
- stacking of one image with one paragraph (you can't generally stack multiple images and multiple paragraphs because of the way the maths works out: an image's height scales proportionally with its width and a paragraph's height scales inversely with its width, so a mixture with more than 2 items doesn't generally have a solution, I think!),
- it potentially works for any type of content you want, but this does have the catch that you need to know (and tell the function) two things about the content you pass to the function:
- does its width drive its size (this is the case for most content), or does its height drive its size
- does its height scale prop. or inv. prop. to its width
Here is the raw source, including a function that typesets proper documentation:
#let _width-for-height(element, l, max-width, volumes-fixed) = {
let lo = 3em.to-absolute()
let hi = max-width - lo
for _ in range(30) {
let mid = (lo + hi) / 2
let h = measure(width: mid, element).height
// != is XOR, which inverts the lhs if the rhs is true; this is because fixed-volume behaves inversely to fixed-ratio
if (h <= l) != (volumes-fixed) {
lo = mid
} else {
hi = mid
}
}
if volumes-fixed { hi } else { lo }
}
#let _height-for-width(element, l, max-height, volumes-fixed) = {
let lo = 3em.to-absolute()
let hi = max-height - lo
for _ in range(30) {
let mid = (lo + hi) / 2
let w = measure(height: mid, element).width
// != is XOR, which inverts the lhs if the rhs is true; this is because fixed-volume behaves inversely to fixed-ratio
if (w <= l) != (volumes-fixed) {
lo = mid
} else {
hi = mid
}
}
if volumes-fixed { hi } else { lo }
}
#let _evaluate_lengths(ratios, total-length) = {
let length0 = total-length / (1.0 + ratios.slice(1).sum() / ratios.at(0))
(length0,) + ratios.slice(1).map(r => r * length0 / ratios.at(0))
}
// ----------------------
// ----- Public API -----
// ----------------------
// Stack the given content in the given direction, automatically sizing the content such that they all have the same length perpendicular to the stack direction. The default direction is `ltr`, so the content is sized automatically to have the same height.
// Note that this will only work correctly with content whose size in the direction parallel to the stack direction can be driven, and whose size in the perpendicular direction depends on the size parallel. Generally speaking, this applies to content with a fixed aspect ratio (images), or a fixed volume (text):
// - Any number of fixed aspect ratio contents may be stacked together.
// - Any number of fixed volume contents may be stacked together; in such a case, set the parameter `volumes-fixed` to `true`.
// - If mixing the two, it is only mathematically feasible with one of each; in such a case, leave the parameter `volumes-fixed` set to `false`.
// In general, the height of content is not driven, instead it is determined by a driving width. As such, by default, when stacking horizontally the heights can be measured directly, and when stacking vertically, widths must be solved for using binary search. If using content whose height drives its width, indicate this by setting the parameter `heights-drive` to `true` if all the given content is as such, or an array of equal length to `..args` with a boolean for each arg in `..args`.
#let stack-equalised(
spacing: 0.5em,
dir: ltr,
volumes-fixed: false,
heights-drive: false,
..args
) = layout(size => {
if spacing == none {
spacing = 0em
}
assert(type(spacing) == length, message: "`spacing` must be a length or none.")
assert(type(volumes-fixed) == bool, message: "`volumes-fixed` must be a bool")
assert(type(dir) == direction, message: "`dir` must be a direction")
let vertical = dir.axis() == "vertical"
assert(args.named().len() == 0, message: "Unrecognized named arguments: " + args.named().keys().join(", "))
let elements = args.pos()
let n = elements.len()
assert(n > 1, message: "At least two elements must be provided.")
let msg = "`heights-drive` must be a bool or an array of bools of equal length to `..args`"
let heights-drive = if type(heights-drive) == array {
assert(heights-drive.len() == n, message: msg)
assert(heights-drive.map(e => type(e) == bool).fold(true, (a, b) => a and b), message: msg)
heights-drive
} else {
assert(type(heights-drive) == bool, message: msg)
(heights-drive,) * n
}
let total-length = ((if vertical { size.height } else { size.width }) - (n - 1) * spacing).to-absolute()
let meausure_ratios(lengths) = {
return elements.zip(lengths, heights-drive).map(t => {
let element = t.at(0)
let l = t.at(1)
let height-drives = t.at(2)
let measured = if vertical {
if height-drives {
measure(height: l, element).width
} else {
_width-for-height(element, l, size.width, volumes-fixed)
}
} else {
if height-drives {
_height-for-width(element, l, size.height, volumes-fixed)
} else {
measure(width: l, element).height
}
}
if volumes-fixed {
// volume conserved, so use the volume
l.pt() * measured.pt()
} else {
// ratio conserved, so use the ratio
l / measured
}
})
}
let iterate(ls) = {
let ret = _evaluate_lengths(meausure_ratios(ls), total-length)
let alpha = 0.5
let ret = ls.zip(ret).map(t => (1.0 - alpha) * t.at(0) + alpha * t.at(1))
let deltas = ls.zip(ret).map(t => t.at(0) - t.at(1))
let mean-square-delta = deltas.map(e => e.pt() * e.pt()).sum() / n
(mean-square-delta > 0.001, ret)
}
let (cont, lengths) = iterate((total-length / n,) * n)
let max-iterations = 50
let i = 0
while cont and i < max-iterations {
let t = iterate(lengths)
cont = t.at(0)
lengths = t.at(1)
i += 1
}
stack(
dir: dir,
spacing: spacing,
..elements.zip(lengths, heights-drive).map(t => {
let element = t.at(0)
let l = t.at(1)
let height-drives = t.at(2)
if vertical {
if height-drives {
box(height: l, element)
} else {
box(width: _width-for-height(element, l, size.width, volumes-fixed), element)
}
} else {
if height-drives {
box(height: _height-for-width(element, l, size.height, volumes-fixed), element)
} else {
box(width: l, element)
}
}
})
)
})
#let explain() = [
#block(breakable: false)[
#let mono-font = "DejaVu Sans Mono"
#set highlight(extent: 2pt, radius: 4pt, top-edge: 1em, bottom-edge: -0.3em)
#let t-none = highlight(fill: yellow.mix(fuchsia).lighten(75%))[none]
#let t-length = highlight(fill: yellow.lighten(50%))[length]
#let t-direction = highlight(fill: aqua.mix(blue).lighten(75%))[direction]
#let t-bool = highlight(fill: yellow.lighten(50%))[bool]
#let t-array = highlight(fill: fuchsia.lighten(75%))[array]
#let t-content = highlight(fill: teal.lighten(50%))[content]
= Parameters
#block(stroke: gray, width: 100%, inset: 1em)[
#set text(font: mono-font)
#text(blue.mix(navy).lighten(25%))[stack-equalised]#text()[(]\
#h(2em) spacing: #t-none #t-length,\
#h(2em) dir: #t-direction,\
#h(2em) volumes-fixed: #t-bool,\
#h(2em) heights-driven: #t-bool #t-array,\
#h(2em) ..#t-content,\
) $arrow.r$ #t-content
]
#let typ(content) = text(font: mono-font, content)
*`spacing`* #h(0.5em) #typ(t-none) or #typ(t-length) #h(1fr) Default: `none`
The spacing between pieces of content.
#v(0.5em)
*`dir`* #h(0.5em) #typ(t-direction) #h(1fr) Default: `ltr`
The direction in which to stack the content.
#v(0.5em)
*`volumes-fixed`* #h(0.5em) #typ(t-bool) #h(1fr) Default: `false`
Whether the contents a have approximately fixed aspect-ratios (`false`) or volumes (`true`).
#v(0.5em)
*`heights-driven`* #h(0.5em) #typ(t-bool) or #typ(t-array) #h(1fr) Default: `false`
Whether the sizes of the pieces of content are driven by their width (`false`) or height (`true`). If a #typ(t-bool) is given, this is used for all pieces of content, otherwise an #typ(t-array) must be given of the same length as #typ([..#t-content]) containing a #typ(t-bool) for each piece of content.
#v(0.5em)
*`children`* #h(0.5em) #typ(t-content) #h(0.5em) _Required_ #h(0.5em) _Positional_ #h(0.5em) _Variadic_
The children to stack along the axis.
]
= Summary
The function `stack-equalised` stacks content using the built-in Typst function `stack`, but sizes the content such that it all has the same height (if stacking horizontally) or width (if stacking vertically).
The extent to which is it possible to do this for some given pieces of content depends on how the widths and heights of the pieces of content depend on one another. Specifically, is the relationship positive or negative?
- Content mostly containing images whose aspect ratios are fixed will generally be taller if given more width, and _vice versa_;
- Content containing mostly text whose total space on the page is roughly fixed will generally be shorter if given more width, and _vice versa_.
Generally speaking, it is possible to stack any number of pieces of content in this way if _all_ of them have approximately fixed aspect ratios. This can be done by calling `stack-equalised` with `volumes-fixed: false` (the default).
It is also generally possible to stack any number of pieces of content in this way if _all_ of them have approximately fixed volumes. This can be done by calling `stack-equalised` with `volumes-fixed: true`.
It is generally possible to stack one fixed-aspect-ratio content next to one fixed-volume content in this way, but not more. This can be done by calling `stack-equalised` with `volumes-fixed: false`, passing the two pieces of content. I think the implementation can be improved upon for this particular use case, but it seems to work as-is, and this use case is already done very well by `oasis-align` (https://github.com/jdpieck/oasis-align/tree/main).
Generally speaking, content is sized by a given width, rather than by a given height, but this is now always the case. For the solver to correctly measure the size of the content it must know the driving dimension of each piece of content. As such, if passing a piece of content to the function whose height is drive, rather than its width, this must be indicated by giving a value to the parameter `heights-drive`:
- If all pieces of content have their sizes driven along the same axis, this can be given a single boolean value: `false` for the width drives (the default) or `true` for the height drives.
- If it is different for different pieces of content, pass an array of booleans, one for each piece of content in order.
= Equalised horizontal stacking of images
Consider that we have $N$ pieces of content whose aspect ratios are fixed (e.g. images). We wish to horizontally stack these pieces of content with appropriate sizes such that they fill the width of the page, and all have equal height.
This has an easy analytical solution as follows. Let
- $w_i$, $h_i$ and $r_i$ denote the width, height and aspect ratio of the $i$th piece of content respectively,
- $W$ denote the total available width in page.
Then,
$
frac(w_i, h_i) = r_i, space.quad
h_1 = h_2 = dots = h_N, space.quad
arrow.r.double frac(w_1, r_1) = frac(w_2, r_2) = dots = frac(w_N, r_N), space.quad
arrow.r.double w_i = frac(r_i, r_1) w_1,
$
and the widths of all the pieces of content must sum to $w$:
$
sum_(i=1)^N w_i = w_1 (1 + frac(1, r_1) sum_(i=2)^N r_i) = W, space.quad
arrow.r.double w_1 = frac(W, 1 + frac(1, r_1) sum_(i=2)^N r_i).
$
Given ${r_1, dots, r_N}$ and $W$, ${w_1, dots, w_N}$ can then be evaluated:
$
w_i = frac(r_i, r_1) frac(W, 1 + frac(1, r_1) sum_(i=2)^N r_i).
$
In reality, however, many pieces of content we may wish to stack as such will not have exactly fixed aspect ratios. For example, if a figure has a caption below it, the caption's height will be fixed, so although the aspect ratio of the image above will be fixed, the aspect ratio of the figure and caption together will be function of the width available: $frac(w, h) = frac(w, h_"image" + h_"caption")$.
In such a case, we can make the assumption that the aspect ratios of the pieces of content are _approximately_ fixed (e.g. that captions are small relative to the resulting sizes of the images), and we can apply the same equations to solve the problem iteratively, re-evaluating the aspect ratios of pieces of content at each iteration. To evaluate the aspect ratio of piece of content, we use the `measure` function. In general, the width of content can be driven, not the height, so the aspect ratio is measured as:
```typst
#context {
width / measure(width: width, content).height
}
```
Denoting iteration $j$ with superscript in brackets, and the function that measures the aspect ratio of piece of content $i$, given a width $w_i$ as $hat(r)_i (w_i)$, one iteration is evaluated as follows:
$
w_i^((j + 1)) = (1 - alpha) w_i^((j)) + alpha frac(hat(r)_i (w_i^((j))), hat(r)_1 (w_1^((j)))) frac(W, 1 + frac(1, hat(r)_1 (w_1^((j)))) sum_(i=2)^N hat(r)_i (w_i^((j)))),
$
with the parameter $alpha in (0, 1]$ to control the speed of convergence.
= Equalised horizontal stacking of text
Consider now that we wish to achieve the same layout with pieces of content containing primarily text. Such content does not have a fixed aspect ratio, rather an approximately fixed _volume_. In such a case, denoting the volume of piece of content $i$ with $v_i$, we instead start with:
$
w_i h_i = v_i, space.quad
arrow.r.double frac(v_1, w_1) = frac(v_2, w_2) = dots = frac(v_N, w_N), space.quad
arrow.r.double w_i = frac(v_i, v_1) w_1,
$
which is the same expression, as above, with $v_i$ substituted for $r_i$! As such, we can immediately arrive at the corresponding solution, changing only the evaluation of the volume of a piece of content to $hat(v)_i (w_i)$, implemented as:
```typst
#context {
width.pt() * measure(width: width, content).height.pt()
}
```
(using `.pt()` as Typst cannot handle units of $"length"^2$);
$
w_i^((j + 1)) = (1 - alpha) w_i^((j)) + alpha frac(hat(v)_i (w_i^((j))), hat(v)_1 (w_1^((j)))) frac(W, 1 + frac(1, hat(v)_1 (w_1^((j)))) sum_(i=2)^N hat(v)_i (w_i^((j)))).
$
= Equalised vertical stacking
Consider now that we wish to achieve the same layout but along a vertical axis, with equal width shared by all pieces of content. We may simply apply the same solutions, swapping the variables $w_i$ for $h_i$ and $W$ for the available height $H$, and making yet another change to the ratio/volume measurement. This solution now requires that the ratio/volume of each piece of content be measured given a driving value for height instead of width. As mentioned above, most content cannot have its height driven, so in such a case we must take an iterative approach.
We wish to estimate the width $w_i^*$ occupied by piece of content $i$ given a fixed height $h_i$, but we only have the ability to measure the height of that piece of content given a fixed width, denoted by the function $hat(h)_i (w)$. To do this for approximately fixed-aspect-ratio content, we use binary search (i.e. compute by bisection) to find the maximum width we can give to the content such that its height is no greater than $h_i$:
$
w_i^* = sup { w in (0, W] | hat(h)_i (w) <= h_i },
$
and for approximately fixed-volume-content, the _minimum_ width we can give to the content such that its height is no greater than $h_i$:
$
w_i^* = inf { w in (0, W] | hat(h)_i (w) <= h_i }.
$
In some rare cases we may have content whose height is driven, rather than its width. In such a case, this iterative approach may be employed (swapping widths for heights) when stacking horizontally, and the simple direct measurement may be employed when stacking vertically. A simple example of piece of content whose height is driven, rather than its width is as follows:
```typst
#let height-driven-box(aspect-ratio) = layout(size => {
box(height: size.height, width: size.height / aspect-ratio, content)
})
```
]