I’m glad I stumbled on this thread! I’ve been trying to duplicate the functionality of LaTeX’s verse package in Typst, but I much prefer the solutions presented here.
Changes to the code
I’ve made some changes to the code; I’d welcome any comments.
- I moved the end-of-stanza label to just after the end-of-line label and before the line break. I found that that’s the only place where the end-of-stanza label could be reliably detected (I’ll explain the use case below).
- I added parameters (and associated code) related to number formatting:
- Placement: an
alignment of either start (in the margin before the start of the line) or end (after the end of the line)
- Interval: an
int for the line numbers that should be shown; for poems I think it looks better to show every fifth or tenth line number, instead of every single one
- Position offset: a
length; this is just the number-pad variable in bluss’ code, but renamed
- I removed the counter for line numbers; instead, I’m using the metadata value for the end-of-line labels to derive the line number. This way, the number interval will work properly.
- I changed labels, some variable names, some formatting, etc., because they seemed clearer to me.
Here’s what I’ve got so far:
#let poem(
text,
numbering: none,
number-placement: end,
number-interval: 5,
number-position-offset: 1em
) = {
// the space at the start is to force us to start the new line..
let line-start = n => "#[#sym.zws#metadata(" + str(n) + ")<_poem:line-start>];"
let line-end = n => "#[#metadata(" + str(n) + ")<_poem:line-end>];"
let stanza-start = "#[#metadata(none)<_poem:stanza-start>];"
let stanza-end = "#[#metadata(none)<_poem:stanza-end>];"
let text = text.text
let stanzas = text.split("\n").split("")
let line-num = 1
let new-markup = for stanza in stanzas {
stanza-start
for (i, line) in stanza.enumerate(start: 1) {
line-start(line-num)
// similar to greenwood-tree's solution for spaces
line.replace(regex("^\ +"), x => "~" * x.text.len()).replace(regex("\ +"), x => " " + "~" * (x.text.len() - 1))
line-end(line-num)
// If it's the last line of the stanza, insert the end-stanza label
// before the line break
if i == stanza.len() { stanza-end }
"#linebreak();\n"
line-num += 1
}
"#parbreak();\n"
}
// place(raw(new-markup, block: true, lang: "typ"), scope: "parent", float: true, top)
// handle line numbering if we want to
context {
let lines = query(
selector.or(<_poem:line-start>, <_poem:line-end>)
.after(here())
.before(selector(<_poem:end>).after(here()))
)
let x-pos = lines.map(elt => elt.location().position().x)
let line-widths = x-pos.chunks(2).map(((a, b)) => b - a)
let max-width = calc.max(0pt, ..line-widths)
show <_poem:line-end>: it => {
if numbering == none { return }
if calc.rem(it.value, number-interval) != 0 { return }
let loc = it.location().position()
let cur-line-start = lines.filter(elt => elt.label == <_poem:line-start> and elt.value == it.value).first()
let line-width = loc.x - cur-line-start.location().position().x
let place-alignment = if number-placement == start { bottom + end }
else if number-placement == end { bottom + start }
let place-dx = if number-placement == start { -line-width - number-position-offset }
else if number-placement == end { max-width - line-width + number-position-offset }
box(
place(
place-alignment,
dx: place-dx,
std.numbering(numbering, it.value)
))
}
eval(new-markup, mode: "markup")
}
[#metadata(none)<_poem:end>]
}
Styling the line numbers can be done like so:
#poem(
numbering: n => {
text(font: "Arial", size: 0.8em)[#n]
},
number-placement: start,
number-interval: 10,
number-position-offset: 1em, ```
There once was a typist named Typst
```)
Use case for stanza labels
It can be difficult to determine whether the first line on a page is also the first line of a new stanza, or if it continues the stanza from the previous page. You can include that information in a header, however, for clarity’s sake.
Here’s an example using the hydra package:
#set page(
header: context hydra(display: (_, it) => {
let title =["#it.body"]
let separator = sym.space.en + [·] + sym.space.en
// get an array with all metadata after the current page's header,
// replace each item in the array with the metadata's label,
// filter for items that contain the stanza label text,
// and select the first item returned
let stanza-label = query(
selector(metadata).after(here())
).map(
x => x.at("label", default: none)
).filter(
x => (x != none) and str(x).contains("_poem:stanza")
).first()
// if the first stanza label is the start label,
// we're beginning a new stanza; otherwise, we're continuing
// the last stanza from the previous page
let stanza-indicator = if str(stanza-label).ends-with("start") { "begin stanza" }
else if str(stanza-label).ends-with("end") { "continue stanza" }
title
separator
[p. #context counter(page).display()]
separator
stanza-indicator
}, 1)
)