Week 13 of 2026

Development log of Erna

7 items
  1. Describe the stacking feature
  2. Set page height to automatic
  3. Implement partial solution for stacks
  4. Describe the stacks and gutters as linear equations
  5. Describe "divide and conquer" approach to stacks and gutters
  6. Implement the divide and conquer solution
  7. Create a preview document and a module to import

Describe the stacking feature

On by Tad Lispy

index ef39395..5b2a3da 100644
--- a/playground.typ
+++ b/playground.typ
@@ -289,3 +289,17 @@ Now we have everything we need! Here are some examples.
=+ Multiple rows, each in an array
=+ Automatic vertical spacing
=+ In a box, to prevent page breaks inside
+
+== TODO: Stack of images within a row
+
+When some images are wide and some narrow, it might be desirable to stack several wide ones on top of each other. In that case, the length of each stacked image should be equal, and the combined height (including gutters) should be equal to the height of every image in the row. In the code, a stack should be represented as an array of paths, like this:
+
+
+```typst
+#image-row(
+    gutter: 4pt,
+    "left.jpg",
+    ("middle-top.jpg", "middlep-bottom.jpg"), // <-- a stack
+    "right.jpg",
+)
+```

Set page height to automatic

On by Tad Lispy

Pages are broken on h2 elements.

index 5b2a3da..6800ddb 100644
--- a/playground.typ
+++ b/playground.typ
@@ -1,3 +1,5 @@
+#set page(height: auto)
+
== Layout Playground
=
=#let images = (
@@ -59,8 +61,8 @@ Sum the aspect ratios of all the images to get the total aspect ratio of the row
=#let total_aspect_ratio = aspects.map(a => a.ratio).sum()
=
=#rect(
-    height: 100% / total_aspect_ratio,
-    width: 100%,
+    height: 300pt / total_aspect_ratio,
+    width: 300pt,
=    align(center + horizon)[1 × #calc.round(digits: 3, total_aspect_ratio)]
=)
=

Implement partial solution for stacks

On by Tad Lispy

It works as long as there are no gutters. Gutters mess up the height.

index 6800ddb..f9341b2 100644
--- a/playground.typ
+++ b/playground.typ
@@ -305,3 +305,79 @@ When some images are wide and some narrow, it might be desirable to stack severa
=    "right.jpg",
=)
=``` 
+
+
+#let describe_image(path) = {
+    let content = image(path)
+    let size = measure(content)
+    let ratio = size.width / size.height
+    return (path: path, content: content, ratio: ratio, ..size)
+}
+
+#let aspect_ratio(item) = {
+    if type(item) == array {
+        // It's a stack! Sum the inverted ratios, then invert it again
+        return 1.0 / item.map(i => 1.0 / i.ratio).sum()
+    } else {
+        return item.ratio
+    }
+}
+
+#let image-row(gutter: 0pt, ..paths) = {
+    context {
+        // An item is either an image or a stack
+        let items = paths.pos().map(path => {
+            if type(path) == array {
+                return path.map(describe_image)
+            } else {
+                return describe_image(path)
+            }
+        })
+
+
+        let total_ratio = items.fold(0, (acc, item) => acc + aspect_ratio(item))
+        let total_gutter = gutter * (items.len() - 1)
+
+        box(layout(parent => grid(
+            columns: items.len(),
+            column-gutter: gutter,
+
+            ..items.map((item) => {
+                let width_fraction = aspect_ratio(item) / total_ratio
+                let final_width = width_fraction * (parent.width - total_gutter)
+
+                if type(item) == array {
+                    grid(
+                        columns: 1,
+                        gutter: gutter,
+                        ..item.map(stacked => {
+                            let scaling_factor = final_width / stacked.width * 100%
+                            scale(stacked.content, scaling_factor, reflow: true)
+                        })
+                    )
+                } else {
+                    let scaling_factor = final_width / item.width * 100%
+                    scale(item.content, scaling_factor, reflow: true)
+                }
+            })
+        )))
+    }
+}
+
+This works now without gutters
+
+#image-row(
+    gutter: 0pt,
+    "300x600.jpg",
+    ("780x300.jpg", "500x480.jpg", "780x300.jpg"),
+    "360x480.jpg",
+)
+
+But adding a gutter messes the height of the stack:
+
+#image-row(
+    gutter: 20pt,
+    "300x600.jpg",
+    ("780x300.jpg", "500x480.jpg", "780x300.jpg"),
+    "360x480.jpg",
+)

Describe the stacks and gutters as linear equations

On by Tad Lispy

index f9341b2..89fb03b 100644
--- a/playground.typ
+++ b/playground.typ
@@ -381,3 +381,149 @@ But adding a gutter messes the height of the stack:
=    ("780x300.jpg", "500x480.jpg", "780x300.jpg"),
=    "360x480.jpg",
=)
+
+== Solution for stacks and gutters
+
+To solve the image row with stacks and gutters we need to think of the problem as a set of $n$ equations with $n$ unknowns, where $n$ is the number of cells in a given row.
+
++ The total width $w$ of the row is known.
+
++ The height $h$ of every cell is equal.
+
++ We must solve for the width of each cell $w_i$
++ The aspect ratio of each cell content $r_i$ can be computed from the ratios of stacked images
+
+
++ For each cell the gutter height $g_i$ is known ($(s_i - 1) * g$) where $s_i$ is the number of stacked images in cell $i$ and $g$ is the given gutter size.
+
+Sum of all widths equal the total width
+
+$
+    w_0 + w_1 + ... + w_n = w
+$
+
+The height of each cell is the same
+
+$
+    h = g_i + w_i / r_i
+$
+
+
+The stack ratio $r_i$ can be calculated as inversion of the sum of inverted aspect ratios of each image.
+
+$
+    r_i = (sum_(j=0)^s r_j^(-1) )^(-1)
+$
+
+Where $s$ is the number of stacked images and $r_j$ is the ration of a stacked image $j$. Inverted ratio $r^(-1)$ is a ratio of height to width, as opposed to regular width to height. Conceptually it's like stacking the images on top of each other, scaling them to have equal width and measuring the aspect ratio of the whole stack.
+
+Let's try to solve it for a simplified set of images.
+
++ 300 ÷ 600 (portrait)
++ 800 ÷ 400 (landscape)
++ 600 ÷ 200 (landscape) <resolutions>
+
+#let total_width = 360pt
+#let gutter = 12pt
+
+
+The last two should be stacked, so there are two rows. Let's say the gutter is #gutter and available space is #total_width.
+
+
+$
+    g = #gutter
+$
+
+For simplicity let's already subtract the vertical gutter from the total width.
+
+$
+    w = #total_width
+$
+
+We can treat each cell as a stack: `(0) (1, 2)`. First cell is a stack of one image, second is a stack of two.
+
+The content ratio of first cell is simple
+
+$
+    r_0
+    = 300 / 600
+    = 0.5
+$
+
+
+The content ratio of the second is
+
+$
+    r_1
+    = 1 / ((800 / 400)^(-1) + (600 / 200)^(-1))
+    = (1/2 + 1/3)^(-1)
+    = 1.2
+$
+
+Now it's possible to solve the set of two equations to find $w_0$ and $w_1$:
+
+$
+    cases(
+        h = g_i + w_i / r_i,
+        w_0 + w_i = w
+    )
+$
+
+Becasue $h$ is the same for each cell we can write it as:
+
+$
+    cases(
+      g_0 + w_0 / r_0 = g_1 + w_1 / r_1,
+      w_0 + w_i = w
+    )
+$
+
+Substituting known values:
+
+$
+    cases(
+        0 + w_0 / 0.5 = 12 + w_1 / 1.2,
+        w_0 + w_1 = 360 \
+    )
+$
+
+Let's reduce the first equation to express $w_0$ in terms of $w_1$.
+
++ $2w_0 = 12 + w_1 / 1.2$
++ $w_0 = 6 + w_1 / 2.4$
+
+Now we can use it in the second equation and solve it:
+
++ $(6 + w_1 / 2.4) + w_1 = 360$
++ $(14.4 + w_1) + 2.4w_1 = 864$ (×2.4)
++ $3.4w_1 = 849.6$
++ $w_1 approx 250$
+
+Knowing the approximate value of $w_1$ and the total width $w$ we can calculate $w_0$ as the remaining length
+
++ $w_0 + w_1 = w$
++ $w_0 + 250 = 360$
++ $w_0 = 110$
+
+Let's check if the height's add up. Again, the image resolutions are are:
+
++ 300 ÷ 600 (portrait, 1:2)
++ 800 ÷ 400 (landscape, 2:1)
++ 600 ÷ 200 (landscape, 3:1)
+
+If the first image width $w_0$ is 110pt, then the height is
+
++ $h = w_0r_0^(-1) + g_0$
++ $h = 110 × 2 + 0$
++ $h = 220$.
+
+The height of two images in the stack combined would be
+
++ $h = w_1r_1^(-1) + g_i$
++ $h = 250 × 1.2^(-1) + 12$
++ $h = 250 × 10/12 + 12$
++ $h approx 220$
+
+It checks! Both cells have approximately the same height.
+
+Now the question is how to scale it arbitrary number of cells. For each cell there would be an additional unknown $w_i$, but also an additional equation $h = w_i r_i + g_i$

Describe "divide and conquer" approach to stacks and gutters

On by Tad Lispy

index 89fb03b..699bf9f 100644
--- a/playground.typ
+++ b/playground.typ
@@ -527,3 +527,18 @@ The height of two images in the stack combined would be
=It checks! Both cells have approximately the same height.
=
=Now the question is how to scale it arbitrary number of cells. For each cell there would be an additional unknown $w_i$, but also an additional equation $h = w_i r_i + g_i$
+
+== Divide and conquer approximation
+
+The equations above seem to work, and I can solve them on paper, but I don't (yet) know how to program a solver in Typst. In the mean time I want to try a "divide and conquer" approach to approximate the height and widths.
+
+In very broad terms:
+
+1. Identify the minimum possible height $h_min$ (that would be the height without any gutters). The current implementation of `image-row` function can calculate this.
+2. Identify the maximum possible height $h_max$ (that would be $h_min + max(g_i)$, so all the gutters without shrinking the content, which would be a case in a single cell containing a stack)
+3. Solve the $h = r_i w_i + g_i$ equations using $h = (h_min + h_max) / 2$ (i.e. midpoint)
+4. If $sum_(i=o)^n w_i approx w$ then that's it.
+5. If $sum_(i=o)^n w_i < w$, set $h_min := h$ and solve again.
+6. If $sum_(i=o)^n w_i > w$, set $h_max := h$ and solve again.
+
+Do it at most 100 times or so. Consider some small length, like 1pt to be sufficiently precise.

Implement the divide and conquer solution

On by Tad Lispy

Stacks and gutters work together!

index 699bf9f..595359b 100644
--- a/playground.typ
+++ b/playground.typ
@@ -526,7 +526,7 @@ The height of two images in the stack combined would be
=
=It checks! Both cells have approximately the same height.
=
-Now the question is how to scale it arbitrary number of cells. For each cell there would be an additional unknown $w_i$, but also an additional equation $h = w_i r_i + g_i$
+Now the question is how to scale it arbitrary number of cells. For each cell there would be an additional unknown $w_i$, but also an additional equation $h = w_i / r_i + g_i$
=
=== Divide and conquer approximation
=
@@ -536,9 +536,205 @@ In very broad terms:
=
=1. Identify the minimum possible height $h_min$ (that would be the height without any gutters). The current implementation of `image-row` function can calculate this.
=2. Identify the maximum possible height $h_max$ (that would be $h_min + max(g_i)$, so all the gutters without shrinking the content, which would be a case in a single cell containing a stack)
-3. Solve the $h = r_i w_i + g_i$ equations using $h = (h_min + h_max) / 2$ (i.e. midpoint)
+3. Solve the $h = w_i / r_i  + g_i$ equations using $h = (h_min + h_max) / 2$ (i.e. midpoint). This can be re-written as $w_i = r_i (h - g_i)$
=4. If $sum_(i=o)^n w_i approx w$ then that's it.
=5. If $sum_(i=o)^n w_i < w$, set $h_min := h$ and solve again.
=6. If $sum_(i=o)^n w_i > w$, set $h_max := h$ and solve again.
=
=Do it at most 100 times or so. Consider some small length, like 1pt to be sufficiently precise.
+
+To make it work:
+
+TODO: Uniform representation of single and stack cell.
+
+Single:
+
+```typst
+(
+  content_ratio: 0.5, 
+  gutters_height: 0pt, 
+  images: (image-1) # Always an array
+)
+```
+
+Stack:
+
+```typst
+(
+  content_ratio: 0.5,
+  gutters_height: 24pt,
+  images: (image-1, image-2)
+)
+```
+
+#let image_ratio(image) = {
+    let size = measure(image)
+    size.width / size.height
+}
+
+#let cell(gutter: 0pt, ..args) = {
+    let paths = args.pos()
+    let images = paths.map(path => image(path))
+    let gutters_height = (images.len() - 1) * gutter
+    let aspect_ratio = 1.0 / images.map(image => 1.0 / image_ratio(image)).sum()
+
+    (
+        images: images,
+        gutters_height: gutters_height,
+        aspect_ratio: aspect_ratio,
+    )
+}
+
+A cell with a single image:
+
+#context cell(
+    gutter: 12pt,
+    "500x480.jpg",
+)
+
+A cell with multiple images:
+
+#context cell(
+    gutter: 6pt,
+    "300x600.jpg",
+    "500x480.jpg",
+    "360x480.jpg",
+    "780x300.jpg",
+)
+
+Now, with the uniform data structure to represent each cell, we can make a new `image_row_dc` function (for divide and conquer).
+
+#let solve(step: 0, max_steps: 64, tolerance: 1pt, min_height, max_height, available_width, cells) = {
+    let height = (min_height + max_height) / 2
+    let widths = cells.map(cell => cell.aspect_ratio * (height - cell.gutters_height))
+
+    let solved_width = widths.fold(0pt, (sum, width) => sum + width)
+    let deviation = calc.abs(available_width - solved_width)
+
+    // TODO: Add to state for debugging purposes, like seeing how many steps it took
+    // [+ #solved_width × #height #widths]
+
+    if deviation < tolerance or step > max_steps {
+        return widths
+    }
+
+    if solved_width > available_width {
+        // Too high
+        solve(step: step + 1, max_steps: max_steps, tolerance: tolerance, min_height, height, available_width, cells)
+    } else {
+        // Too low
+        solve(step: step + 1, max_steps: max_steps, tolerance: tolerance, height, max_height, available_width, cells)
+    }
+
+    
+}
+
+#let image_row_dc(gutter: 0pt, ..args) = {
+    context {
+        let cells = args.pos().map(paths => {
+            if type(paths) == array {
+                cell(gutter: gutter, ..paths)
+            } else {
+                cell(gutter: gutter, paths)
+            }
+        })
+
+        layout(parent => {
+            let gutters_width = (cells.len() - 1) * gutter
+            let available_width = parent.width - gutters_width
+
+            // Theoretical minimum height, as if horizontal gutters where not there
+            let min_ratio = cells.fold(0, (sum, cell) => sum + cell.aspect_ratio)
+            let min_height = available_width / min_ratio
+
+            // Theoretical maximum height would be the minimum height and all the gutters
+            let max_gutters = cells.fold(0pt, (current_max, cell) => calc.max(current_max, cell.gutters_height))
+            let max_height = min_height + max_gutters
+
+            // TODO: Add to state for debugging purposes
+            // [#(
+            //     cells: cells,
+            //     available_width: available_width,
+            //     min_height: min_height,
+            //     max_height: max_height,
+            // )]
+
+            let widths = solve(min_height, max_height, available_width, cells)
+
+            // TODO: Add to state for debugging
+            // [widths #widths]
+
+            grid(
+                gutter: gutter,
+                columns: cells.len(),
+                ..cells.enumerate().map(((index, cell)) => {
+                    grid(
+                        gutter: gutter,
+                        columns: widths.at(index),
+                        ..cell.images
+                    )
+                })
+            )
+        
+        })
+    }
+}
+
+Here's a row with gutter and a stack in the middle cell:
+
+#image_row_dc(
+    gutter: 12pt,
+    "300x600.jpg",
+    ("780x300.jpg", "500x480.jpg", "780x300.jpg"),
+    "360x480.jpg",
+)
+
+Same, without the gutter:
+
+#image_row_dc(
+    gutter: 0pt,
+    "300x600.jpg",
+    ("780x300.jpg", "500x480.jpg", "780x300.jpg"),
+    "360x480.jpg",
+)
+
+With a large gutter:
+
+#image_row_dc(
+    gutter: 20pt,
+    "300x600.jpg",
+    ("780x300.jpg", "500x480.jpg", "780x300.jpg"),
+    "360x480.jpg",
+)
+
+Only one stack in a box (kind of usesless, could just be images)
+
+#box(
+    width: 200pt,
+    stroke: 1pt + blue,
+
+    image_row_dc(
+        gutter: 20pt,
+        ("780x300.jpg", "500x480.jpg", "780x300.jpg"),
+    )
+)
+
+In a box, only singular cells:
+
+#box(
+    width: 300pt,
+    stroke: 1pt + blue,
+
+    image_row_dc(
+        gutter: 2pt,
+        "780x300.jpg", "500x480.jpg", "780x300.jpg",
+    )
+)
+
+Two stacks:
+
+#image_row_dc(
+    gutter: 3pt,
+    ("780x300.jpg", "500x480.jpg", "780x300.jpg"),
+    ( "300x600.jpg", "360x480.jpg"),
+)

Create a preview document and a module to import

On by Tad Lispy

The module is called Erna, after my dear friend who inspired me to figure this out.

new file mode 100644
index 0000000..2517109
Binary files /dev/null and b/500x280.jpg differ
new file mode 100644
index 0000000..503ed6f
Binary files /dev/null and b/960x250.jpg differ
new file mode 100644
index 0000000..633687d
--- /dev/null
+++ b/erna.typ
@@ -0,0 +1,93 @@
+#let image_ratio(image) = {
+    let size = measure(image)
+    size.width / size.height
+}
+
+#let cell(gutter: 0pt, ..args) = {
+    let paths = args.pos()
+    let images = paths.map(path => image(path))
+    let gutters_height = (images.len() - 1) * gutter
+    let aspect_ratio = 1.0 / images.map(image => 1.0 / image_ratio(image)).sum()
+
+    (
+        images: images,
+        gutters_height: gutters_height,
+        aspect_ratio: aspect_ratio,
+    )
+}
+
+#let solve(step: 0, max_steps: 64, tolerance: 1pt, min_height, max_height, available_width, cells) = {
+    let height = (min_height + max_height) / 2
+    let widths = cells.map(cell => cell.aspect_ratio * (height - cell.gutters_height))
+
+    let solved_width = widths.fold(0pt, (sum, width) => sum + width)
+    let deviation = calc.abs(available_width - solved_width)
+
+    // TODO: Add to state for debugging purposes, like seeing how many steps it took
+    // [+ #solved_width × #height #widths]
+
+    if deviation < tolerance or step > max_steps {
+        return widths
+    }
+
+    if solved_width > available_width {
+        // Too high
+        solve(step: step + 1, max_steps: max_steps, tolerance: tolerance, min_height, height, available_width, cells)
+    } else {
+        // Too low
+        solve(step: step + 1, max_steps: max_steps, tolerance: tolerance, height, max_height, available_width, cells)
+    }
+
+    
+}
+
+#let image_row(gutter: 0pt, ..args) = {
+    context {
+        let cells = args.pos().map(paths => {
+            if type(paths) == array {
+                cell(gutter: gutter, ..paths)
+            } else {
+                cell(gutter: gutter, paths)
+            }
+        })
+
+        layout(parent => {
+            let gutters_width = (cells.len() - 1) * gutter
+            let available_width = parent.width - gutters_width
+
+            // Theoretical minimum height, as if horizontal gutters where not there
+            let min_ratio = cells.fold(0, (sum, cell) => sum + cell.aspect_ratio)
+            let min_height = available_width / min_ratio
+
+            // Theoretical maximum height would be the minimum height and all the gutters
+            let max_gutters = cells.fold(0pt, (current_max, cell) => calc.max(current_max, cell.gutters_height))
+            let max_height = min_height + max_gutters
+
+            // TODO: Add to state for debugging purposes
+            // [#(
+            //     cells: cells,
+            //     available_width: available_width,
+            //     min_height: min_height,
+            //     max_height: max_height,
+            // )]
+
+            let widths = solve(min_height, max_height, available_width, cells)
+
+            // TODO: Add to state for debugging
+            // [widths #widths]
+
+            grid(
+                gutter: gutter,
+                columns: cells.len(),
+                ..cells.enumerate().map(((index, cell)) => {
+                    grid(
+                        gutter: gutter,
+                        columns: widths.at(index),
+                        ..cell.images
+                    )
+                })
+            )
+        
+        })
+    }
+}
new file mode 100644
index 0000000..08e02a8
--- /dev/null
+++ b/preview.typ
@@ -0,0 +1,83 @@
+#import "erna.typ": image_row
+
+#show heading.where(level: 1): content => {
+    pagebreak(weak: true)
+    content
+    v(1em, weak: true)
+}
+
+#title[Erna]
+
+Erna places her images in neat rows.
+
+
+#image_row(
+    gutter: 12pt,
+    ("780x300.jpg", "500x480.jpg", "960x250.jpg"),
+    ("300x600.jpg", "360x480.jpg"),
+)
+ 
+Erna is a Typst library for aligning images in neat rows. It's easy to use. I made it for a friend who struggles with aligning images in LibreOffice. It's named after her. With Erna (the library), you can:
+
+- Cram as many images as you see fit!
+- Bravely mix sizes, resolutions and aspect ratios. Let the computer figure it out!
+- Got some wiiide images? Stack 'em up!
+
+Erna's promises:
+
+#set list(marker: sym.checkmark.heavy)
+
+- To fill all the available width. \
+- Make the images align vertically. \
+- Preserve aspect ratios.
+
+#pagebreak()
+
+#show raw.where(lang: "typ"): content => {
+    
+    content
+
+    eval(content.text, mode: "markup", scope: (image_row: image_row))
+}
+
+= Basic use
+
+At the heart of it is the `image_row` function. It takes a list of image paths, and scales them to equal height and to fit the available width, while preserving aspect ratio of each image. No cropping!
+
+```typ
+#image_row(
+    "300x600.jpg", "360x480.jpg", "500x480.jpg",
+)
+```
+
+
+= With gutters
+
+You can put some space between the images with the `gutter` option.
+
+```typ
+#image_row(
+    gutter: 4pt,
+    "300x600.jpg", "360x480.jpg", "500x480.jpg",
+)
+```
+
+
+= With stacks
+
+It's possible to place several images in a vertical stack. This is useful when mixing portraits and landscapes. Just group the paths in a parentheses to create a stack.
+
+```typ
+#image_row(
+    gutter: 6pt,
+    "360x480.jpg", 
+    ("500x280.jpg", "780x300.jpg"),
+    ("960x250.jpg", "500x480.jpg"), 
+)
+```
+
+= TODO: Multiple rows
+
+= TODO: Diagram
+
+Brought to you by #link("https://tad-lispy.com/")[Tad Lispy].