How to force line breaks without extra spaces

Hi everyone,

As part of a personal project, I’m trying to find a way to force line breaks in specific sections of text without needing to manually add an extra space between lines.

For example, this input:

line1
line2
line3

Should give

line1
line2
line3

And not

line1 line2 line3

I believe I might need to look into show rules using the par object, but I’m not entirely sure. Since I only want this formatting to apply in specific cases (e.g., not for headings or certain text segments), I’m also considering using regex with custom delimiters like "" or something similar.

For example with the regex :

// pseudocode that shouldn't compile
#show regex("\"\"(.*?)\"\"") : it => {
  for line in it {
    line + linebreak()
  }
}

Any advice would be much appreciated—thanks in advance!

EDIT 1 : The objective of the template is to set up a student songbook with a lot of adjoinging lines such as I’d like to avoid people specifying a line break with \

1 Like

Hello. You can use sugar/markup syntax for linebreak element.

line1 \
line2 \
line3

image

Sorry, I wasn’t very clear in my initial message. I’m in the process of developing a template that would ensure that the \ must not be used.

This may seem like a strange comment, but as my template allows me to make a songbook containing a lot of adjoining lines, I’d like to avoid people specifying a line break

1 Like

Yeah, that is not what I’ve read. The title is also not what you are describing. I think you want “How to force line breaks between adjacent lines without explicit linebreak?”

Do you use any special markup syntax at all? You can easily use raw element for this purpose:

#show raw: set text(11pt, font: "Libertinus Serif")
// #show raw: set par(leading: 0.65em)

```
line1
line2
line3
```

If your lines are not a one continuous text element, then it’s impossible to detect a soft line break, as it’s just a space.

#show: it => for child in it.children {
  if child.func() == [ ].func() { linebreak() } else { child }
}

#lorem(2)
#lorem(2)
#lorem(2)

word --- line1
word --- line2
word --- line3

image

This is just like raw element workaround, but with extra steps and not much better.

Actually, you can just use #show [ ].func(): linebreak(), but either way, it will replace any standalone space element with a linebreak. This doesn’t feel robust.


An actual solution would be to not rely on plain markup to distinguish between separate adjacent lines. Pass all lines as separate arguments to a function. Or, I guess you can exploit the list element, like I did in the past:

#let hack(body) = {
  set list(marker: none, body-indent: 0pt)
  body
}

#hack[
  - word --- line1
  - word --- line2
  - word --- line3
]
1 Like

As far as I know, raw is the only element in typst that allows you to preserve whitespace and linebreaks (it comes with some drawbacks - it’s a verbatim environment, can’t apply any other styling to the text, typst markup is not valid inside raw blocks).

But raw is has one configurability, it’s parametrized by “language type” and we can use that to style song lyrics specifically for example like this…

#show raw.where(lang: "lyrics"): set text(font: "Libertinus Sans")

```lyrics
Blackbird singing in the dead of night
Take these broken wings and learn to fly
All your life
You were only waiting for this moment to arise

Blackbird singing in the dead of night
Take these sunken eyes and learn to see
All your life
You were only waiting for this moment to be free

Blackbird, fly
Blackbird, fly
Into the light of the dark black night
```

bild

If the book is only lyrics, then maybe the marker lyrics is not necessary either.
And there are packages that style code and raw blocks - which means it can be done similarly for songs as well, I think.

3 Likes

Speaking of packages, there are a few:
https://typst.app/universe/search?kind=packages&q=song
https://typst.app/universe/search?kind=packages&q=lyrics

1 Like

Some of those packages look nice and advanced, but from a quick look they don’t seem to provide anything like what Alex is asking for.

This feature request is relevant: Customizing source line break behavior · Issue #710 · typst/typst · GitHub It’s formulated for poems, but it’s the same idea.

1 Like

I think it’s a viable option and a good compromise between ease and performance for a project that’s likely to include ~600 pages. I’ll come back to the blogpost when I have more convincing results.

I came to this conclusion thanks to the various responses shared.

The first and simplest (though a bit more verbose) solution is to use show rules with a raw.where(lang: "lyrics") selector:

#show raw.where(lang: "lyrics"): set text(font: "Liberation Sans")

'''lyrics
line1  
line2  
line3
'''

The second approach involves using a regex to define custom delimiters:

#show regex("\"(.*?)\""): it => {
  it.at("text")
    .split(" ")
    .filter(l => l != "\"")
    .join(linebreak())
}
As a result :

Both solutions require a similar amount of special characters to define a verse. However, the second method is better when using additional formatting inside of the delimiter which should not be concidered as raw . Finally, the second method is logically more resource-intensive and should be tested further for performance at scale.

Note that if you have markup that breaks up your text, like ab _cd_ then regex cannot/will not match across the different styles, it can only test and match on ab and cd separately.

I’m skeptical of the regex rules in general, they are really quite limited.


Apparently I continued hacking on this: here’s how we can really extend the raw block to support more markup. We use a show rule to evaluate markup on each line. Note the limitation - it evaluates one line at a time. And like you said, every solution might need performance evaluation. Eval markup for example brings back smartquotes, i.e automatic “” or «» as appropriate per language. (And, markup eval also removes the space-preserving property of raw blocks).

So this becomes a relatively nice way to typeset a poem or a song.

#let functions = (
  ul: std.underline,
)

#show raw.where(lang: "poem"): set text(font: "EB Garamond")
#show raw.where(lang: "poem"): set raw(theme: none)
#show raw.where(lang: "poem"): it => {
  show raw.line: it => {
    // Evaluate typst markup in the raw block
    // NOTE: can only see one line at a time, line-spanning markup not supported.
    // Add eval's scope argument to make custom functions available

    if it.number != 0 {
      // preserve initial space on the line
      let prefix = if it.text.starts-with(" ") {
        let space-width = 0.5em
        let space-prefix = it.text.position(regex("\S"))
        if space-prefix != none {
          h(space-width * space-prefix)
        }
      }
      raw.line(0, it.count, it.text, prefix + eval(it.text, mode: "markup", scope: functions))
    } else {
      it
    }
  }
  it
}


```poem
*Under the greenwood tree*
Under the greenwood tree #emoji.tree
Who loves to _lie with me,_
And turn his merry note
Unto the sweet bird's throat,
Come hither, come hither, come hither:
  Here shall he see
  No enemy
#ul[But winter and rough weather.]

Who doth ambition shun, #emoji.sloth
And loves to live i' the sun, $smash$
Seeking the food he eats,
And pleas'd with what he gets,
Come hither, come hither, come hither:
  Here shall he see
  No enemy
#ul[But winter and rough weather.]
```
2 Likes

Thanks @bluss , this works well for me, and can be modified to one’s taste and needs.
E.g., for poems/lyrics along with no poem text, I found pleasant:

  1. to add some padding:
    show raw.where(lang: "poem"): block.with(inset: (x: 2em, y: 1em))

  2. to increase spacing between lines:
    show raw.where(lang: "poem"): set par(leading: 0.76em)

  3. to reduce spacing of blank lines (in a poem, e.g. to separate stanzas):
    if it.text == "" { v(-0.6em) }

So @bluss code will become:

#let functions = (
  ul: std.underline,
)

#show raw.where(lang: "poem"): block.with(inset: (x: 2em, y: 1em)) // pad text
#show raw.where(lang: "poem"): set par(leading: 0.76em) // increase spacing between lines
#show raw.where(lang: "poem"): set text(font: "EB Garamond")
#show raw.where(lang: "poem"): set raw(theme: none)
#show raw.where(lang: "poem"): it => {
  show raw.line: it => {
    // Evaluate typst markup in the raw block
    // NOTE: can only see one line at a time, line-spanning markup not supported.
    // Add eval's scope argument to make custom functions available

    if it.number != 0 {
      if it.text == "" {
         // reduce spacing of blank lines
          v(-0.6em)
      } else {
        // preserve initial space on the line
        let prefix = if it.text.starts-with(" ") {
          let space-width = 0.5em
          let space-prefix = it.text.position(regex("\S"))
          if space-prefix != none {
            h(space-width * space-prefix)
          }
        }
        raw.line(0, it.count, it.text, prefix + eval(it.text, mode: "markup", scope: functions))
      }
    } else {
      it
    }
  }
  it
}

I don’t see why there’s a need to eval line-by-line – it is also possible to eval the entire text, if we replace newlines and double newlines with \ and #parbreak():

#show raw.where(lang: "poem"): it => {
  set par(leading: 0.76em) // increase spacing between lines
  set text(font: "EB Garamond", size: 1em * 1.25) //factor of 1.25 cancels default raw font-size
  set raw(theme: none)
  let space-width = 0.5em
  block(
    inset: (x: 2em, y: 1em),
    eval(
      it.text
        .replace(regex("\n\n+"), "#parbreak()")
        .replace(regex("\n( *)"), (i) => {
          "\ "
          if i.captures.at(0).len() > 0 { "#h(" + repr(i.captures.at(0).len() * space-width) + ")" }
        }),
      mode: "markup",
      scope: (
        :
        // add whatever you need here
      )
    )
  )
}

This then allows for line-spanning markup (see the italics in the second part) and allows handling blank lines as proper paragraph breaks.

With this many repetitions, it’s better to use let raw-poem = raw.where(lang: "poem").

The original example I’ve expanded upon and it’s currently hosted here: greenwood-tree · main · bluss / typst-recipes · GitLab (if anyone is interested, we should collaborate to publish it).

I prefer this interface now:

#poem(```
The poem
goes here
```)

We should avoid using show rules on raw, because we don’t want to inherit its default style. I want to inherit important settings like document language, font, size etc from the surrounding document. It’s impossible reset raw’s font and language in a way so that this happens.

I think that’s a great idea. For various reasons I rejected it before, but it deserves a closer look. It definitely gets a bit more messy (relying more on eval for injected code), but it looks like it can enable multiline markup (but not multiline code mode).

1 Like

Yeah, I agree that the show rules are a bit iffy. My proposed solution can be adapted 1:1 to this new interface by replacing

#show raw.where(lang: "poem"): it => {

with

#let poem(it) = {

and removing the * 1.25 from the font size. (Which then makes the font size setting superfluous)

Here’s a proof of concept for the multiline suggestion, exploring how to do line numbers. It’s feasible but involves injecting more code; I think it’s working pretty well. It’s left unfinished for now, some things left to work out if one continues down this path.

#set page(columns: 2)

#show raw: set block(width: calc.inf * 1pt) // no wrap
#let poem3(text, numbering: none) = {
  // the space at the start is to force us to start the new line..
  let linestart = n => "#sym.zws#[#metadata(" + str(n) + ")<_poem_linestart>];"
  let lineend = n => "#[#metadata(" + str(n) + ")<_poem_lineend>];"
  let stanzastart = "#[#metadata(none)<_poem_stanzastart>];"
  let stanzaend = "#[#metadata(none)<_poem_stanzaend>];"

  let text = text.text
  let stanzas = text.split("\n").split("")
  let lineno = 1
  let new-markup = for stanza in stanzas {
    stanzastart
    for line in stanza {
      linestart(lineno)
      // similar to greenwood-tree's solution for spaces
      line.replace(regex("^\ +"), x => "~" * x.text.len()).replace(regex("\ +"), x => " " + "~" * (x.text.len() - 1))
      lineend(lineno)
      "#linebreak();\n"
      lineno += 1
    }
    stanzaend
    "#parbreak();\n"
  }
  place(raw(new-markup, block: true, lang: "typ"), scope: "parent", float: true, top)

  // handle line numbering if we want to
  let linecounter = counter("_poem_line")
  linecounter.update(0)
  context {
    let lines = query(
      selector.or(<_poem_linestart>, <_poem_lineend>)
      .after(here())
      .before(selector(<_poem_end>).after(here()))
    )
    let x-pos = lines.map(elt => elt.location().position().x)
    let linewidths = x-pos.chunks(2).map(((a, b)) => b - a)
    let maxwidth = calc.max(0pt, ..linewidths)
    let number-pad = 2em
    show <_poem_lineend>: it => {
      if numbering == none { return }
      let loc = it.location().position()
      let thisstart = lines.filter(elt => elt.label == <_poem_linestart> and elt.value == it.value).first()
      let linewidth = loc.x - thisstart.location().position().x

      box(place(bottom + left, dx: maxwidth - linewidth + number-pad, {
        linecounter.step()
        context {
          linecounter.display(numbering)
        }
      }))
    }
    eval(new-markup, mode: "markup")
  }
  [#metadata(none)<_poem_end>]
}

#show sym.space.nobreak: sym.space.third

#poem3(numbering: "1",
```typ
Under the greenwood tree #emoji.tree
_Who loves to lie with me,_
And turn his merry note
Unto the sweet bird's throat,
Come hither,  come hither,   come hither:
  _Here shall he see
    No enemy_
#[But winter and rough weather.]

Who doth ambition shun, #emoji.leaf.herb
_And loves to live i' the sun,_
Seeking the food he eats,
And pleas'd with what he gets,
Come hither,  come hither,   come hither:
  _Here shall he see
    No enemy_
#[But winter and rough weather.]

*What if one line is very very very very very very very very very very long?
Let's see.*
```)