diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts b/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts
index 055a33ad69e..8d21c1d01b9 100755
--- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts
+++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts
@@ -125,4 +125,23 @@ describe("lines()", () => {
"an important line",
])
})
+
+ it("should properly close and re-open HTML tags that span multiple lines", () => {
+ // the HTML version of this string won't fit into a width of 150, but it will once the HTML tags are stripped
+ // - that's what the rawHtml mode is for.
+ const text = "a very important message here"
+ const wrap = new TextWrap({
+ text,
+ maxWidth: 10,
+ fontSize: FONT_SIZE,
+ rawHtml: true,
+ })
+ expect(wrap.lines.map((l) => l.text)).toEqual([
+ "a",
+ "very",
+ "important",
+ "message",
+ "here",
+ ])
+ })
})
diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
index 36a900227cd..b0336f51ee7 100644
--- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
+++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
@@ -25,8 +25,12 @@ interface WrapLine {
height: number
}
-const HTML_OPENING_TAG_REGEX = /<(\w+)[^>]*>/
-const HTML_CLOSING_TAG_REGEX = /<\/(\w)+>/
+interface OpenHtmlTag {
+ tag: string // e.g. "a" for an tag, or "span" for a tag
+ fullTag: string // e.g. ""
+}
+
+const HTML_OPENING_CLOSING_TAG_REGEX = /<(\/?)([A-Za-z]+)[^>]*>/g
function startsWithNewline(text: string): boolean {
return /^\n/.test(text)
@@ -78,6 +82,50 @@ export class TextWrap {
return this.props.text
}
+ // We need to take care that HTML tags are not split across lines.
+ // Instead, we want every line to have opening and closing tags for all tags that appear.
+ // This is so we don't produce invalid HTML.
+ processHtmlTags(lines: WrapLine[]): WrapLine[] {
+ const currentlyOpenTags: OpenHtmlTag[] = []
+ for (const line of lines) {
+ // Prepend any still-open tags to the start of the line
+ const prependOpenTags = currentlyOpenTags
+ .map((t) => t.fullTag)
+ .join("")
+
+ const tagMatches = line.text.matchAll(
+ HTML_OPENING_CLOSING_TAG_REGEX
+ )
+ for (const tag of tagMatches) {
+ const isOpeningTag = tag[1] !== "/"
+ if (isOpeningTag) {
+ currentlyOpenTags.push({
+ tag: tag[2],
+ fullTag: tag[0],
+ })
+ } else {
+ if (
+ !currentlyOpenTags.length ||
+ currentlyOpenTags.at(-1)?.tag !== tag[2]
+ ) {
+ throw new Error(
+ "TextWrap: Opening and closing HTML tags do not match"
+ )
+ }
+ currentlyOpenTags.pop()
+ }
+ }
+
+ // Append any unclosed tags to the end of the line
+ const appendCloseTags = [...currentlyOpenTags]
+ .reverse()
+ .map((t) => `${t.tag}>`)
+ .join("")
+ line.text = prependOpenTags + line.text + appendCloseTags
+ }
+ return lines
+ }
+
@computed get lines(): WrapLine[] {
const { text, maxWidth, fontSize, fontWeight } = this
@@ -136,7 +184,9 @@ export class TextWrap {
height: lineBounds.height,
})
- return lines
+ // Process HTML to ensure that each opening tag has a matching closing tag _in each line_
+ if (this.props.rawHtml) return this.processHtmlTags(lines)
+ else return lines
}
@computed get height(): number {