How can I automatically apply a balanced two-column layout only to paragraphs?

Hi!
I’m looking for a way to “automatically” apply a two-column layout only to paragraphs (and not to headings, for example). Ideally, I should be able to write something like:

= Report Overview
<report-overview>

lorem ipsum lorem ipsumlorem ipsumlorem ipsumlorem
ipsumlorem ipsumlorem ipsumlorem ipsumlorem
ipsumlorem ipsum ipsumlorem ipsum ipsumlorem ipsum
ipsumlorem ipsum ipsumlorem ipsum ipsumlorem ipsum

lorem ipsum lorem ipsumlorem ipsumlorem ipsumlorem
ipsumlorem ipsumlorem ipsumlorem ipsumlorem
ipsumlorem ipsum ipsumlorem ipsum ipsumlorem ipsum
ipsumlorem ipsum ipsumlorem ipsum ipsumlorem ipsum

= Key Takeaways
<key-takeaways>

lorem ipsum lorem ipsumlorem ipsumlorem ipsumlorem
ipsumlorem ipsumlorem ipsumlorem ipsumlorem
ipsumlorem ipsum ipsumlorem ipsum ipsumlorem ipsum
ipsumlorem ipsum ipsumlorem ipsum ipsumlorem ipsum

lorem ipsum lorem ipsumlorem ipsumlorem ipsumlorem
ipsumlorem ipsumlorem ipsumlorem ipsumlorem
ipsumlorem ipsum ipsumlorem ipsum ipsumlorem ipsum
ipsumlorem ipsum ipsumlorem ipsum ipsumlorem ipsum

I’m basically looking for the best way to do something like this, ideally (I don’t know if it’s possible) without always having to specify it with a grid() or equivalent.

Hi @JosephBarbier! I have changed your post’s title to bring it in line with the question guidelines and thus make it easier to understand from the title:

Good titles are questions you would ask your friend about Typst.

I also added the layout and layout-containers (for columns) tags, as it makes your question easier to find.

If you think I didn’t hit the mark with the new title, feel free to edit it again but keep it a question please :slight_smile:

1 Like

Thanks, that makes a lot of sense! I wasn’t really sure what my question is about so this helps.

Hello!

Unfortunately, balanced layouting of columns is not supported (yet), see Balancing the contents of multiple columns to the same vertical space · Issue #466 · typst/typst · GitHub

We can work around this by manually calculating what the height of the columns should be, and using a show rule on par to apply this balanced layout automatically:

// Reference:
// https://typst.app/docs/reference/layout/page/#parameters-margin
// Source:
// https://forum.typst.app/t/how-to-draw-at-the-margin-of-a-page/1708/2
#let calc-margin(margin, shape) = if margin == auto {
  2.5 / 21 * calc.min(..shape)
} else {
  margin
}

#show par: it => context {
  // balanced columns
  let num-cols = 2
  let gutter = columns.gutter  // change this as needed
  
  let width = page.width - 2 * calc-margin(page.margin, (page.width, page.height))
  width = width * (100% - gutter.ratio * (num-cols - 1))
  width = width - gutter.length * (num-cols - 1)
  width = width / num-cols
  
  let height = measure(it, width: width).height

  let buffer = if num-cols == 2 {0pt} else {1em}
  // The last column may overflow due to rounding,
  // so we might need to add some extra space.
  // More likely if there are more columns
  
  block(
    height: height / num-cols + buffer, 
    columns(num-cols, gutter: gutter, it)
  )
}

Note about the answer: I am not 100% sure if I calculate the width properly. After testing, it seems fine, but it could be off in some edge cases.

3 Likes

Instead of adding a buffer, we can also measure again:

#show par: it => layout(size => {
  let column-count = 2
  let columnized = columns(column-count, it)
  
  let textheight = measure(columnized, width: size.width).height / column-count
  let height = measure(columnized, height: textheight + 0.9em, width: size.width).height
  let height = measure(columnized, height: height, width: size.width).height
  block(height: height, width: size.width, columnized)
})
  • The third measure is there because I’ve found that it sometimes has extra whitespace otherwise (in particular when used for outlines or whenever the body contains non-text elements, it might be fine to remove it for this usecase)

  • We find the width by using layout – this means this also works if the paragraph is inside some container

  • This is adapted from my colum-balancing function:

    See code taking a columns
    /// takes columns
    #let balance(content) = layout(size => {
      let count = content.at("count")
    
      let textheight = measure(content, width: size.width).height / count
      let height = measure(content, height: textheight + 0.9em, width: size.width).height
      let height = measure(content, height: height, width: size.width).height
      block(height: height, width: size.width, content)
    })
    

EDIT: Looking at the example you give, it seems like you want all paragraphs between headings to be combined into one columns. I don’t think that that is possible automatically[1], you probably need to wrap each section into a #balance(columns(2, [...])) using the balance function given in “See code taking a columns” EDIT EDIT: See new answer below

Example Code
/// takes columns
#let balance(content) = layout(size => {
  let count = content.at("count")

  let textheight = measure(content, width: size.width).height / count
  let height = measure(content, height: textheight + 0.9em, width: size.width).height
  let height = measure(content, height: height, width: size.width).height
  block(height: height, width: size.width, content)
})

= Balanced Columns
#balance(columns(2, [

#lorem(20)

#lorem(40)

#lorem(30)

]))


#show par: it => layout(size => {
  let column-count = 2
  let columnized = columns(column-count, it)
  
  let textheight = measure(columnized, width: size.width).height / column-count
  let height = measure(columnized, height: textheight + 0.9em, width: size.width).height
  let height = measure(columnized, height: height, width: size.width).height
  block(height: height, width: size.width, columnized)
})

= Auto-balanced Paragraphs <key-takeaways>

#lorem(20)

#lorem(40)

#lorem(30)


  1. Addendum: You could probably write a document show rule (#show: document=> {}) that automatically groups paragraphs together into columns, but i haven’t gotten the energy to figure out how rn ↩︎

2 Likes

Alternative solution if you want all paragraphs between headings to be grouped into columns layouts:

#let balance(content) = layout(size => {
  let count = content.at("count")

  let textheight = measure(content, width: size.width).height / count
  let height = measure(content, height: textheight + 0.9em, width: size.width).height
  let height = measure(content, height: height, width: size.width).height
  block(height: height, width: size.width, content)
})

#show: document => {
  let current-columns = none
  for child in document.children {
    if (child.func() == heading and child.depth == 1) or (child.func() == pagebreak) /* or (child.func() == raw and child.block) or ... */ {
      if current-columns != none {
        balance(columns(2, current-columns.join()))
        current-columns = none
      }
      child
    } else {
      if current-columns == none {
        current-columns = ()
      }
      current-columns.push(child)
    }
  }
  if current-columns != none { balance(columns(2, current-columns.join())) }
}

  • Change the line with if child.func() ... to filter for whatever elements you want to have break the columns. The example only has forst-level headings break the columns, but you could put if child.func() in (heading, pagebreak, ) { to have all headings break the columns etc.
    • You mustn’t have pagebreaks inside the columns, as this is not allowed.

Additional limitation: you can’t have set or show rules in your document flow, since that will put the rest of the content into a styled element. So that would be detected as a single thing, instead of a sequence of paragraphs and other elements.

(sorry for bringing bad news :see_no_evil:)

1 Like

Generally speaking, you would just make a styled(styles: element.styles, element.child) wrapper and mutate the child how you want. I did not read the code, might not work here.

I always get styled with let styled = text(red)[].func().

Yeah, it was mostly meant as a general remark about the limits of “inspecting elements directly approaches”

here, mutating the styled child doesn’t fully work, because the element hierarchy and document structure are separate. You would need to effectively split

#set ...

abc

= Def

into

#[
  #set ...
  abc
]

#[
  #set ...
  = Def
]

to be able to treat the paragraphs separately from the heading, and that can probably have unintended consequences.

1 Like