///|
/// A run of text with one emphasis, or an icon or typeset math in place of text.
pub(all) struct Span {
mut text : String
style : Int32
/// The name of the icon the span draws; its `text` is then empty.
icon : String?
mut width : Double
/// Typeset math the span stands for, drawn in place of its text.
math : MathMarkup?
}
///|
/// Text laid out in lines of spans, measured.
pub(all) struct Label {
lines : Array[Array[Span]]
widths : Array[Double]
width : Double
height : Double
size : Double
line_height : Double
/// The height of each line, present only when math makes a line taller than `line_height`.
heights : Array[Double]?
}
///|
const LINE_HEIGHT : Double = 1.5
///|
/// Lines of one wrapped label sit closer, so the label reads as one block.
const WRAPPED_LINE_HEIGHT : Double = 1.3
///|
/// The line height of a label of `count` lines, given that of a single line.
fn snug(count : Int32, size : Double, line_height : Double) -> Double {
if count > 1 {
(size * WRAPPED_LINE_HEIGHT).round()
} else {
line_height
}
}
///|
/// The room an icon takes in a line, in multiples of the font size.
const ICON_WIDTH : Double = 1.25
///|
fn empty_label() -> Label {
{
lines: [],
widths: [],
width: 0,
height: 0,
size: 0,
line_height: 0,
heights: None,
}
}
///|
/// Break between any two Chinese or Japanese characters.
/// Hangul is left out: Korean puts spaces between words.
fn is_cjk(c : Int32) -> Bool {
(c >= 0x2e80 && c <= 0x9fff) ||
(c >= 0xf900 && c <= 0xfaff) ||
(c >= 0xff00 && c <= 0xff60)
}
///|
let close_marks = "、。,.:;!?)]}」』】〕〉》〙〗ー…‥・ゝゞぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ,.:;!?)]}"
///|
let open_marks = "([{「『【〔〈《〘〖([{"
///|
fn is_close(c : Int32) -> Bool {
c >= 0 && close_marks.contains_code_unit(c.to_uint16())
}
///|
fn is_open(c : Int32) -> Bool {
c >= 0 && open_marks.contains_code_unit(c.to_uint16())
}
///|
/// A break hint: a zero-width space, or a soft hyphen, which shows as `-` where a line breaks.
fn is_hint(c : Int32) -> Bool {
c == 0x200b || c == 0xad
}
///|
fn has_hint(text : String) -> Bool {
text.contains_code_unit(0x200b) || text.contains_code_unit(0xad)
}
///|
fn skip_space(text : String, from : Int32) -> Int32 {
let mut i = from
while @util.is_space(@util.code_at(text, i)) {
i += 1
}
i
}
///|
/// The end of the wrapping piece at `i`, as
/// `\s*(?:[OPEN]*[CJK][CLOSE]*|[^\s CJK HINT]+[HINT]*[CLOSE]*|[HINT]+)\s*|\s+` matches it, or -1.
fn piece_end(text : String, i : Int32) -> Int32 {
let j = skip_space(text, i)
let mut k = j
while is_open(@util.code_at(text, k)) {
k += 1
}
for m = k; m >= j; m = m - 1 {
if is_cjk(@util.code_at(text, m)) {
let mut e = m + 1
while is_close(@util.code_at(text, e)) {
e += 1
}
return skip_space(text, e)
}
}
let c = @util.code_at(text, j)
if c != -1 && !@util.is_space(c) && !is_cjk(c) && !is_hint(c) {
let mut e = j
while e < text.length() &&
!@util.is_space(@util.code_at(text, e)) &&
!is_cjk(@util.code_at(text, e)) &&
!is_hint(@util.code_at(text, e)) {
e += 1
}
while is_hint(@util.code_at(text, e)) {
e += 1
}
while is_close(@util.code_at(text, e)) {
e += 1
}
return skip_space(text, e)
}
if is_hint(c) {
let mut e = j
while is_hint(@util.code_at(text, e)) {
e += 1
}
return skip_space(text, e)
}
if @util.is_space(@util.code_at(text, i)) {
return skip_space(text, i)
}
-1
}
///|
/// The pieces a line may break between.
fn pieces(text : String) -> Array[String] {
let out = []
let mut i = 0
while i < text.length() {
let end = piece_end(text, i)
if end > i {
out.push(@util.slice(text, i, end~))
i = end
} else {
i += 1
}
}
out
}
///|
fn dedent(text : String) -> String {
let lines = text.split("\n").map(line => line.to_owned()).to_array()
let mut least = -1
for line in lines {
if @util.trim(line) == "" {
continue
}
let indent = line.length() - @util.trim_start(line).length()
if least == -1 || indent < least {
least = indent
}
}
if least <= 0 {
return text
}
lines
.map(line => {
let indent = line.length() - @util.trim_start(line).length()
@util.slice(line, if least < indent { least } else { indent })
})
.join("\n")
}
///|
/// The first of the private use characters that stand for TeX while a label's markup is read, so
/// that tags and emphasis markers inside TeX are left alone.
const MARK : Int32 = 0xe000
///|
const MAX_MARKS : Int32 = 0x1900
///|
fn is_mark(c : Int32) -> Bool {
c >= 0xe000 && c <= 0xf8ff
}
///|
/// The TeX each mark of the label being laid out stands for, and what it was typeset as.
priv struct Marks {
tex : Array[String]
math : Array[MathMarkup?]
}
///|
let marks : Ref[Marks?] = { val: None, }
///|
/// Whether the span is text, which a neighbor of the same emphasis can join.
fn plain(span : Span) -> Bool {
span.icon is None && span.math is None
}
///|
fn push_plain(line : Array[Span], text : String, style : Int32) -> Unit {
let decoded = decode_entities(text)
match line.last() {
Some(last) if plain(last) && last.style == style =>
last.text = last.text + decoded
_ => line.push({ text: decoded, style, icon: None, width: 0, math: None, })
}
}
///|
fn push_text(line : Array[Span], text : String, style : Int32) -> Unit {
if text == "" {
return
}
guard marks.val is Some(found) && has_mark(text) else {
push_plain(line, text, style)
return
}
let mut last = 0
for i in 0.. last {
push_plain(line, @util.slice(text, last, end=i), style)
}
last = i + 1
let at = c - MARK
match found.math[at] {
Some(math) =>
line.push({
text: "",
style,
icon: None,
width: math.width,
math: Some(math),
})
None => push_plain(line, "$$\{found.tex[at]}$$", style)
}
}
if last < text.length() {
push_plain(line, @util.slice(text, last), style)
}
}
///|
fn has_mark(text : String) -> Bool {
for i in 0.. Bool {
c == '\n'.to_int() || c == '\r'.to_int() || c == 0x2028 || c == 0x2029
}
///|
/// The end of the TeX of `\$\$(.+?)\$\$` matched at `at`, or -1.
fn math_end(text : String, at : Int32) -> Int32 {
if !@util.starts_with_at(text, "$$", at) {
return -1
}
let mut e = at + 2
while e < text.length() && !is_line_terminator(@util.code_at(text, e)) {
if @util.starts_with_at(text, "$$", e + 1) {
return e + 1
}
e += 1
}
-1
}
///|
/// Replaces each `$$TeX$$` with a mark, as `raw.replace(/\$\$(.+?)\$\$/g, …)` does, collecting
/// the TeX in `found`.
fn mark_math(raw : String, found : Marks) -> String {
let out = StringBuilder()
let mut last = 0
let mut at = 0
while at < raw.length() {
let end = math_end(raw, at)
if end == -1 {
at += 1
continue
}
if found.tex.length() < MAX_MARKS {
@util.write_range(out, raw, last, at)
found.tex.push(@util.slice(raw, at + 2, end~))
out.write_char(Int32::unsafe_to_char(MARK + found.tex.length() - 1))
last = end + 2
}
at = end + 2
}
@util.write_range(out, raw, last, raw.length())
out.to_string()
}
///|
/// The next `(fa[bklrs]?):fa-([\w-]+)` at or after `from`, as its start and end.
fn find_icon(text : String, from : Int32) -> (Int32, Int32)? {
let mut p = @util.index_of(text, "fa", from~)
while p != -1 {
let q = p + 2
let variant = @util.code_at(text, q)
let name = if variant >= 0 &&
"bklrs".contains_code_unit(variant.to_uint16()) &&
@util.starts_with_at(text, ":fa-", q + 1) {
q + 5
} else if @util.starts_with_at(text, ":fa-", q) {
q + 4
} else {
-1
}
if name != -1 {
let mut end = name
while @util.is_word(@util.code_at(text, end)) ||
@util.code_at(text, end) == '-'.to_int() {
end += 1
}
if end > name {
return Some((p, end))
}
}
p = @util.index_of(text, "fa", from=p + 1)
}
None
}
///|
/// An icon the host does not have leaves no gap, nor the space that set it off from the text.
fn push_icons(line : Array[Span], text : String, style : Int32) -> Unit {
if !text.contains(":fa-") {
push_text(line, text, style)
return
}
let mut last = 0
let mut search = 0
while find_icon(text, search) is Some((start, end)) {
push_text(line, @util.slice(text, last, end=start), style)
last = end
search = end
let icon = @util.slice(text, start, end~)
if icon_markup(icon) != "" {
line.push({ text: "", style, icon: Some(icon), width: 0, math: None, })
} else if @util.code_at(text, last) == ' '.to_int() {
last += 1
} else if last == text.length() {
if line.last() is Some(before) && plain(before) {
before.text = @util.trim_end(before.text)
}
}
}
push_text(line, @util.slice(text, last), style)
}
///|
/// The end of the tag `<\/?([a-zA-Z][\w-]*)(?:\s[^<>]*)?\/?>` at `at`, and its name.
fn tag_at(source : String, at : Int32) -> (Int32, String)? {
let mut i = at + 1
if @util.code_at(source, i) == '/'.to_int() {
i += 1
}
if !@util.is_letter(@util.code_at(source, i)) {
return None
}
let name_start = i
while @util.is_word(@util.code_at(source, i)) ||
@util.code_at(source, i) == '-'.to_int() {
i += 1
}
let name = @util.slice(source, name_start, end=i)
let c = @util.code_at(source, i)
if @util.is_space(c) {
let mut m = i + 1
while m < source.length() &&
@util.code_at(source, m) != '<'.to_int() &&
@util.code_at(source, m) != '>'.to_int() {
m += 1
}
return if @util.code_at(source, m) == '>'.to_int() {
Some((m + 1, name))
} else {
None
}
}
if c == '/'.to_int() && @util.code_at(source, i + 1) == '>'.to_int() {
return Some((i + 2, name))
}
if c == '>'.to_int() {
return Some((i + 1, name))
}
None
}
///|
/// The HTML tags open at the end of a line, as they can span explicit breaks.
priv struct Open {
mut style : Int32
/// Only the innermost `` or `` shifts text.
shifts : Array[Int32]
}
///|
fn tag_style(tag : String) -> Int32 {
match tag {
"b" | "strong" => STYLE_BOLD
"i" | "em" | "cite" | "dfn" | "var" => STYLE_ITALIC
"code" | "kbd" | "samp" | "tt" => STYLE_MONO
"sub" => STYLE_SUB
"sup" => STYLE_SUP
"u" | "ins" => STYLE_UNDERLINE
_ => 0
}
}
///|
/// The HTML elements whose tags a label drops, keeping their text.
let html_tags : Set[String] = Set::from_array(
("a abbr address article aside audio b bdi bdo big blockquote body br button caption center cite code col " +
"colgroup data dd del details dfn dialog div dl dt em embed fieldset figcaption figure font footer form h1 h2 " +
"h3 h4 h5 h6 head header hr html i iframe img input ins kbd label legend li link main map mark meta meter nav " +
"noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script section select " +
"small source span strike strong style sub summary sup table tbody td template textarea tfoot th thead time " +
"title tr track tt u ul var video wbr")
.split(" ")
.map(name => name.to_owned())
.to_array(),
)
///|
/// Splits inline HTML and, for markdown labels, emphasis markers into styled spans.
fn inline(
line : Array[Span],
source : String,
markdown : Bool,
base : Int32,
open : Open,
) -> Unit {
let mut style = open.style
let mut marked = 0
// The text between tags is taken as slices of the source, joined only across a tag that sets no
// emphasis, because copying a long line a code unit at a time into a doubling builder outgrows
// what wasm can allocate.
let text = StringBuilder()
let mut joined = false
let mut run = 0
let flush = (at : Int32) => {
let piece = if joined {
@util.write_range(text, source, run, at)
let piece = text.to_string()
text.reset()
piece
} else {
@util.slice(source, run, end=at)
}
joined = false
let shift = match open.shifts.last() {
Some(bit) => bit
None => 0
}
push_icons(line, piece, style | shift)
}
let n = source.length()
let mut i = 0
while i < n {
let ch = @util.code_at(source, i)
if ch == '<'.to_int() {
if tag_at(source, i) is Some((end, name)) {
let tag = name.to_lower()
let bit = tag_style(tag)
// Other HTML tags are dropped and their text kept.
// Anything else shaped like a tag, such as the type in List, is text.
if bit != 0 || html_tags.contains(tag) {
let closing = @util.code_at(source, i + 1) == '/'.to_int()
if (bit & (STYLE_SUB | STYLE_SUP)) != 0 {
flush(i)
if !closing {
open.shifts.push(bit)
} else {
for at = open.shifts.length() - 1; at >= 0; at = at - 1 {
if open.shifts[at] == bit {
open.shifts.remove(at) |> ignore
break
}
}
}
} else if bit != 0 {
flush(i)
open.style = if closing {
(open.style & bit.lnot()) | (base & bit)
} else {
open.style | bit
}
style = if closing {
(style & bit.lnot()) | (base & bit)
} else {
style | bit
}
} else {
@util.write_range(text, source, run, i)
joined = true
}
run = end
i = end
continue
}
}
} else if markdown && (ch == '*'.to_int() || ch == '_'.to_int()) {
let double = @util.code_at(source, i + 1) == ch
let bit = if double { STYLE_BOLD } else { STYLE_ITALIC }
let length = if double { 2 } else { 1 }
let open = (marked & bit) == 0
let prev = if i > 0 { @util.code_at(source, i - 1) } else { ' '.to_int() }
let next = if i + length < n {
@util.code_at(source, i + length)
} else {
' '.to_int()
}
let marker = @util.slice(source, i, end=i + length)
let can_open = open &&
!@util.is_space(next) &&
@util.index_of(source, marker, from=i + length) != -1
let can_close = !open && !@util.is_space(prev)
let wordy = ch == '_'.to_int() &&
@util.is_word(prev) &&
@util.is_word(next)
if (can_open || can_close) && !wordy {
flush(i)
marked = if can_open { marked | bit } else { marked & bit.lnot() }
style = if can_open {
style | bit
} else {
(style & bit.lnot()) | (base & bit)
}
i += length
run = i
continue
}
}
i += 1
}
flush(n)
}
///|
fn measure_spans(
line : Array[Span],
measurer : TextMeasurer,
size : Double,
) -> Double {
let mut total = 0.0
for span in line {
span.width = match (span.icon, span.math) {
(Some(_), _) => size * ICON_WIDTH
(None, Some(math)) => math.width
(None, None) => measurer.width(span.text, size, span.style)
}
total += span.width
}
total
}
///|
/// Breaks a line into lines no wider than `max` where it can, appending them to `out`.
fn wrap(
line : Array[Span],
measurer : TextMeasurer,
size : Double,
max : Double,
out : Array[Array[Span]],
widths : Array[Double],
) -> Unit {
let mut current : Array[Span] = []
let mut width = 0.0
// Text joined to the last span of `current` and not yet copied into it, because copying the
// span for each zero-width piece would take quadratic time.
let pending = StringBuilder()
let settle = () => {
if !pending.is_empty() {
if current.last() is Some(last) {
last.text = last.text + pending.to_string()
}
pending.reset()
}
}
let commit = (hyphen : Bool) => {
settle()
if current.last() is Some(last) && plain(last) {
let mut trimmed = @util.trim_end(last.text)
if hyphen && last.text.has_suffix("\u{ad}") {
trimmed = @util.slice(trimmed, 0, end=trimmed.length() - 1) + "-"
}
if trimmed != last.text {
last.text = trimmed
let w = measurer.width(trimmed, size, last.style)
width += w - last.width
last.width = w
}
}
out.push(current)
widths.push(width)
current = []
width = 0
}
for at, span in line {
if !plain(span) {
let w = match span.math {
Some(math) => math.width
None => size * ICON_WIDTH
}
if width > 0 && width + w > max {
commit(false)
}
settle()
current.push({ ..span, width: w, })
width += w
continue
}
let pieces = pieces(span.text)
// A piece ending in a soft hyphen fits if its `-` does, or if the rest of the word does, which
// hides the hyphen.
// The line only grows along a word, so no later hyphen in it fits either, and the remaining
// width of each word makes this one check.
let soft = span.text.contains_code_unit(0xad)
let n = if soft { pieces.length() } else { 0 }
let own = FixedArray::make(n, 0.0)
let rest = FixedArray::make(n + 1, 0.0)
for q = n - 1; q >= 0; q = q - 1 {
// A soft hyphen before a space is no break of its own: the space is.
let shy = pieces[q].has_suffix("\u{ad}")
let end = @util.trim_end(pieces[q])
own[q] = measurer.width(
if shy {
@util.slice(end, 0, end=end.length() - 1)
} else {
end
},
size,
span.style,
)
// Word width beyond this span is unknown.
rest[q] = if !shy {
own[q]
} else if q == n - 1 {
if at == line.length() - 1 {
own[q]
} else {
@double.infinity
}
} else {
own[q] + rest[q + 1]
}
}
for p, piece in pieces {
let mut part = piece
let part_width = if soft {
own[p]
} else {
measurer.width(@util.trim_end(part), size, span.style)
}
let fits = if !soft || !part.has_suffix("\u{ad}") {
width + part_width <= max
} else {
width +
measurer.width(
@util.slice(part, 0, end=part.length() - 1) + "-",
size,
span.style,
) <=
max ||
width + rest[p] <= max
}
if width > 0 && !fits {
// Breaking at whitespace leaves a preceding soft hyphen hidden.
commit(@util.trim_start(part) == part)
part = @util.trim_start(part)
}
match current.last() {
Some(last) if plain(last) && last.style == span.style => {
pending.write_string(part)
// Remeasuring for each zero-width piece would take quadratic time.
if part_width > 0 || @util.trim_end(part) != part {
settle()
let before = last.width
last.width = measurer.width(last.text, size, last.style)
width += last.width - before
}
}
_ => {
settle()
let w = measurer.width(part, size, span.style)
current.push({
text: part,
style: span.style,
icon: None,
width: w,
math: None,
})
width += w
}
}
}
}
if current.length() > 0 || out.length() == 0 {
commit(false)
}
}
///|
/// Splits at `
`, `\n` written out, and line breaks, as `/
|\\n|\n/gi` does.
fn split_breaks(text : String) -> Array[String] {
let parts = []
let mut last = 0
let mut i = 0
while i < text.length() {
let c = @util.code_at(text, i)
let mut end = -1
if c == '\n'.to_int() {
end = i + 1
} else if c == '\\'.to_int() &&
(
@util.code_at(text, i + 1) == 'n'.to_int() ||
@util.code_at(text, i + 1) == 'N'.to_int()
) {
end = i + 2
} else if c == '<'.to_int() &&
@util.slice(text, i + 1, end=i + 3).to_lower() == "br" {
let mut k = skip_space(text, i + 3)
if @util.code_at(text, k) == '/'.to_int() {
k += 1
}
if @util.code_at(text, k) == '>'.to_int() {
end = k + 1
}
}
if end == -1 {
i += 1
} else {
parts.push(@util.slice(text, last, end=i))
last = end
i = end
}
}
parts.push(@util.slice(text, last))
parts
}
///|
fn layout_lines(
raw : String,
markdown : Bool,
measurer : TextMeasurer,
size : Double,
max_width : Double,
base : Int32,
line_height : Double,
) -> Label {
let source = if markdown {
@util.trim(dedent(@util.normalize_line_breaks(raw)))
} else {
@util.trim(raw)
}
let lines : Array[Array[Span]] = []
let widths : Array[Double] = []
let open = { style: base, shifts: [], }
for part in split_breaks(source) {
let line = []
inline(line, @util.trim(part), markdown, base, open)
let w = measure_spans(line, measurer, size)
if w > max_width {
wrap(line, measurer, size, max_width, lines, widths)
} else {
lines.push(line)
widths.push(w)
}
}
let mut width = 0.0
for w in widths {
if w > width {
width = w
}
}
let line_height = snug(lines.length(), size, line_height)
{
lines,
widths,
width,
height: lines.length().to_double() * line_height,
size,
line_height,
heights: None,
}
}
///|
fn has_markup(text : String) -> Bool {
for i in 0.. Bool {
let mut p = @util.index_of(text, "fa")
while p != -1 {
let variant = @util.code_at(text, p + 2)
if @util.starts_with_at(text, ":fa-", p + 2) ||
(
variant >= 0 &&
"bklrs".contains_code_unit(variant.to_uint16()) &&
@util.starts_with_at(text, ":fa-", p + 3)
) {
return true
}
p = @util.index_of(text, "fa", from=p + 1)
}
false
}
///|
fn has_cjk(text : String) -> Bool {
for i in 0.. Label {
guard raw is Some(raw) && raw != "" else { return empty_label() }
let line_height = (size * LINE_HEIGHT).round()
if math_active() && raw.contains("$$") && !has_mark(raw) {
let found = { tex: [], math: [], }
let marked = mark_math(raw, found)
if found.tex.length() > 0 {
for tex in found.tex {
found.math.push(typeset(decode_entities(tex), size))
}
return layout_markup(
marked,
markdown,
measurer,
size,
max_width,
base,
line_height,
Some(found),
)
}
}
if !has_markup(raw) && !raw.contains("&") && !raw.contains("\u{fb02}") {
let text = @util.trim(raw)
let width = measurer.width(text, size, base)
if width <= max_width ||
!(text.contains(" ") || has_cjk(text) || has_hint(text)) {
return {
lines: [[{ text, style: base, icon: None, width, math: None, }]],
widths: [width],
width,
height: line_height,
size,
line_height,
heights: None,
}
}
let lines : Array[Array[Span]] = []
let widths : Array[Double] = []
wrap(
[{ text, style: base, icon: None, width, math: None, }],
measurer,
size,
max_width,
lines,
widths,
)
let mut widest = 0.0
for w in widths {
if w > widest {
widest = w
}
}
let line_height = snug(lines.length(), size, line_height)
return {
lines,
widths,
width: widest,
height: lines.length().to_double() * line_height,
size,
line_height,
heights: None,
}
}
layout_markup(
raw,
markdown,
measurer,
size,
max_width,
base,
line_height,
None,
)
}
///|
/// Lays out markup with `found` as the TeX its marks stand for.
fn layout_markup(
raw : String,
markdown : Bool,
measurer : TextMeasurer,
size : Double,
max_width : Double,
base : Int32,
line_height : Double,
found : Marks?,
) -> Label {
let outer = marks.val
marks.val = found
defer {
marks.val = outer
}
let label = layout_lines(
raw, markdown, measurer, size, max_width, base, line_height,
)
if found is Some(_) {
with_heights(label)
} else {
label
}
}
///|
/// The label with line heights for the math it holds, or without them when no line needs more
/// than `line_height`.
pub fn with_heights(label : Label) -> Label {
let mut heights : Array[Double]? = None
for i, line in label.lines {
let mut h = label.line_height
for span in line {
if span.math is Some(math) {
h = @util.js_max(h, (math.height + label.size / 4).ceil())
}
}
if h != label.line_height && heights is None {
heights = Some(label.lines.map(_ => label.line_height))
}
if heights is Some(heights) {
heights[i] = h
}
}
guard heights is Some(heights) else {
return if label.heights is None {
label
} else {
{
..label,
heights: None,
height: label.lines.length().to_double() * label.line_height,
}
}
}
let mut height = 0.0
for h in heights {
height += h
}
{ ..label, heights: Some(heights), height, }
}
///|
/// Whether a line holds typeset math.
pub fn has_math(line : Array[Span]) -> Bool {
line.iter().any(span => span.math is Some(_))
}
///|
/// The middle of line `i`, from the top of the label.
pub fn line_middle(label : Label, i : Int32) -> Double {
guard label.heights is Some(heights) else {
return (i.to_double() + 0.5) * label.line_height
}
let mut top = 0.0
for k in 0.. Label {
let width = label.widths[i]
let height = match label.heights {
Some(heights) => heights[i]
None => label.line_height
}
{
lines: [label.lines[i]],
widths: [width],
width,
height,
size: label.size,
line_height: label.line_height,
heights: if height != label.line_height {
Some([height])
} else {
None
},
}
}