How to make bullet list item bodies flow like paragraphs?

Hi folks.

I am trying to make bullet list item bodies flow like paragraphs. That is, when text wrapping occurs, instead of wrapping up to the bullet point I want it to wrap to the left page margin, like a regular paragraph would.

This snippet works, but only for single level lists:

#show list: it => {
  for item in it.children {
    [#it.marker #item.body #parbreak()]
  }
}

- #lorem(20)
- #lorem(20)

If we have a multilevel list:

- #lorem(20)
  - #lorem(20)
  - #lorem(20)
- #lorem(20)
  - #lorem(20)
  - #lorem(20)

The nested items just append at the end of the top level item.

Inspecting the it.children I see that nested items are wrapped into sequence, but checking with type(item.body) == array is not working. They all have content type.

Any ideas on how to check for nested items or maybe a more elegant solution altogether?

Hi @magackame,

this is a beginning…

#show list : set par(hanging-indent: -1em) 
- lorem(20)

- lorem(20)

  • Note the required space between the two items (to make it not tight, otherwise it doesn’t work).
  • There is an issue still opened on enum lists Hanging-indent for enum lists Β· Issue #5751 Β· typst/typst Β· GitHub that may well be indicating this is a dead-end for now… there is probably another member that will come up with some fancier solution to make the multi-level work, but so far I haven’t been able to.

Edit: I could not come up with something clean with your code, it rendered multiple markers in an array, which was not what you sent screenshots of.

Hi. I also need this, which is why I had to implement it, but now I checked it again, and it didn’t work correctly, so I fixed it:

#let correctly-indent-list-and-enum-items(doc) = {
  let first-line-indent() = if type(par.first-line-indent) == dictionary {
    par.first-line-indent.amount
  } else {
    par.first-line-indent
  }

  show list: li => {
    for (i, it) in li.children.enumerate() {
      let nesting = state("list-nesting", 0)
      let indent = context h((nesting.get() + 1) * li.indent)
      let marker = context {
        let n = nesting.get()
        if type(li.marker) == array {
          li.marker.at(calc.rem-euclid(n, li.marker.len()))
        } else if type(li.marker) == content {
          li.marker
        } else {
          li.marker(n)
        }
      }
      let body = {
        nesting.update(x => x + 1)
        it.body + parbreak()
        nesting.update(x => x - 1)
      }
      let content = {
        marker
        h(li.body-indent)
        body
      }
      context pad(left: int(nesting.get() != 0) * li.indent, content)
    }
  }

  show enum: en => {
    let start = if en.start == auto {
      if en.children.first().has("number") {
        if en.reversed { en.children.first().number } else { 1 }
      } else {
        if en.reversed { en.children.len() } else { 1 }
      }
    } else {
      en.start
    }
    let number = start
    for (i, it) in en.children.enumerate() {
      number = if it.has("number") { it.number } else { number }
      if en.reversed { number = start - i }
      let parents = state("enum-parents", ())
      let indent = context h((parents.get().len() + 1) * en.indent)
      let num = if en.full {
        context numbering(en.numbering, ..parents.get(), number)
      } else {
        numbering(en.numbering, number)
      }
      let max-num = if en.full {
        context numbering(en.numbering, ..parents.get(), en.children.len())
      } else {
        numbering(en.numbering, en.children.len())
      }
      num = context box(
        width: measure(max-num).width,
        align(right, text(overhang: false, num)),
      )
      let body = {
        parents.update(arr => arr + (number,))
        it.body + parbreak()
        parents.update(arr => arr.slice(0, -1))
      }
      if not en.reversed { number += 1 }
      let content = {
        num
        h(en.body-indent)
        body
      }
      context pad(left: int(parents.get().len() != 0) * en.indent, content)
    }
  }
  doc
}

Now it will work like

#lorem(20)

- #lorem(20)

  - #lorem(20)

    - #lorem(20)

      #lorem(20)
- #lorem(20)

- #lorem(20)

#lorem(20)

+ #lorem(20)

  + #lorem(20)

    + #lorem(20)

      #lorem(20)
+ #lorem(20)

+ #lorem(20)

But you don’t have to space them:

#set list(indent: 1em)
#set enum(indent: 1em, full: true)
#set par(justify: true, first-line-indent: (amount: 1em, all: true))

#show: correctly-indent-list-and-enum-items

#lorem(20)
- #lorem(20)
  - #lorem(20)
    - #lorem(20)

      #lorem(20)
- #lorem(20)
- #lorem(20)
#lorem(20)
+ #lorem(20)
  + #lorem(20)
    + #lorem(20)

      #lorem(20)
+ #lorem(20)
+ #lorem(20)

I think I’ve implemented all the features that list/enums have to offer, with exception for tightness and stuff. I don’t use terms because it’s not a thing in documents I make.

The code @Andrew gave works nicely.

Thank you @Andrew @vmartel08!

I just came across this, looking for a solution to the same problem. Many thanks to @Andrew for this code.

To the developers: in the future, it would be nice if the first-line indent and the hanging indent could both be settable parameters of the list and enum functions, so as to not require this work around and impact upon other paragraphs.

Tried to compile a document, and found out that this solution gives an error for PDF/UA-1:

error: PDF/UA-1 error: invalid list (L) structure
    β”Œβ”€ file.typ:200:20
    β”‚
200 β”‚         num = context box(
    β”‚ ╭─────────────────────^
201 β”‚ β”‚         width: measure(max-num).width,
202 β”‚ β”‚         align(right, text(overhang: false, num)),
203 β”‚ β”‚       )
    β”‚ ╰───────^
    β”‚
    = hint: list (L) may not contain paragraph (P)
    = hint: this is probably caused by a show rule

error: PDF/UA-1 error: invalid list (L) structure
    β”Œβ”€ file.typ:200:20
    β”‚
200 β”‚         num = context box(
    β”‚ ╭─────────────────────^
201 β”‚ β”‚         width: measure(max-num).width,
202 β”‚ β”‚         align(right, text(overhang: false, num)),
203 β”‚ β”‚       )
    β”‚ ╰───────^
    β”‚
    = hint: list (L) may not contain paragraph (P)
    = hint: this is probably caused by a show rule

error: PDF/UA-1 error: invalid list (L) structure
    β”Œβ”€ file.typ:200:20
    β”‚
200 β”‚         num = context box(
    β”‚ ╭─────────────────────^
201 β”‚ β”‚         width: measure(max-num).width,
202 β”‚ β”‚         align(right, text(overhang: false, num)),
203 β”‚ β”‚       )
    β”‚ ╰───────^
    β”‚
    = hint: list (L) may not contain paragraph (P)
    = hint: this is probably caused by a show rule

So I can’t provide improved accessibility, basically because Hanging-indent for lists Β· Issue #5751 Β· typst/typst Β· GitHub is not implemented. Though technically/semantically, it still won’t be a paragraph.


I just now realized that the two overrides don’t work together, so I can’t nest enum in list. Just updating the second state with a special value that doesn’t modify the numbering/markers behavior is enough, though not very clean.

/// Spacing doesn't work the same way as native solution if par leading and
/// spacing are different.
#let correctly-indent-list-and-enum-items(doc) = {
  let first-line-indent() = if type(par.first-line-indent) == dictionary {
    par.first-line-indent.amount
  } else {
    par.first-line-indent
  }

  show list: li => {
    for (i, it) in li.children.enumerate() {
      let nesting = state("list-nesting", 0)
      let indent = context h((nesting.get() + 1) * li.indent)
      let get-nesting() = calc.div-euclid(nesting.get(), 10)
      let marker = context {
        let n = get-nesting()
        if type(li.marker) == array {
          li.marker.at(calc.rem-euclid(n, li.marker.len()))
        } else if type(li.marker) == content {
          li.marker
        } else {
          li.marker(n)
        }
      }
      let parents = state("enum-parents", ()) // Support enum nesting.
      let body = {
        parents.update(arr => arr + (-1,))
        nesting.update(x => x + 10)
        it.body + parbreak()
        nesting.update(x => x - 10)
        parents.update(arr => arr.slice(0, -1))
      }
      let content = {
        marker
        h(li.body-indent)
        body
      }
      context pad(left: int(nesting.get() != 0) * li.indent, content)
    }
  }

  show enum: en => {
    let start = if en.start == auto {
      if en.children.first().has("number") {
        if en.reversed { en.children.first().number } else { 1 }
      } else {
        if en.reversed { en.children.len() } else { 1 }
      }
    } else {
      en.start
    }
    let number = start
    for (i, it) in en.children.enumerate() {
      number = if it.number != auto { it.number } else { number }
      if en.reversed { number = start - i }
      let parents = state("enum-parents", ())
      let get-parents() = parents.get().filter(x => x >= 0)
      let indent = context h((get-parents().len() + 1) * en.indent)
      let num = if en.full {
        context numbering(en.numbering, ..get-parents(), number)
      } else {
        numbering(en.numbering, number)
      }
      let max-num = if en.full {
        context numbering(en.numbering, ..get-parents(), en.children.len())
      } else {
        numbering(en.numbering, en.children.len())
      }
      num = context box(
        width: measure(max-num).width,
        align(right, text(overhang: false, num)),
      )
      let list-nesting = state("list-nesting", 0) // Support list nesting.
      let body = {
        parents.update(arr => arr + (number,))
        list-nesting.update(x => x + 1)
        it.body + parbreak()
        list-nesting.update(x => x - 1)
        parents.update(arr => arr.slice(0, -1))
      }
      if not en.reversed { number += 1 }
      let content = {
        num
        h(en.body-indent)
        body
      }
      context pad(left: int(parents.get().len() != 0) * en.indent, content)
    }
  }
  doc
}