Skip to content

Commit

Permalink
enhance: TextWrap opens and closes every HTML tag
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelgerber authored and danyx23 committed Nov 17, 2023
1 parent 91b6115 commit 0352b27
Show file tree
Hide file tree
Showing 2 changed files with 72 additions and 3 deletions.
19 changes: 19 additions & 0 deletions packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,23 @@ describe("lines()", () => {
"an <strong>important</strong> <a href='https://youtu.be/dQw4w9WgXcQ'>line</a>",
])
})

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 <strong><i>very important</i> message</strong> here"
const wrap = new TextWrap({
text,
maxWidth: 10,
fontSize: FONT_SIZE,
rawHtml: true,
})
expect(wrap.lines.map((l) => l.text)).toEqual([
"a",
"<strong><i>very</i></strong>",
"<strong><i>important</i></strong>",
"<strong>message</strong>",
"here",
])
})
})
56 changes: 53 additions & 3 deletions packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a> tag, or "span" for a <span> tag
fullTag: string // e.g. "<a href='https://ourworldindata.org'>"
}

const HTML_OPENING_CLOSING_TAG_REGEX = /<(\/?)([A-Za-z]+)[^>]*>/g

function startsWithNewline(text: string): boolean {
return /^\n/.test(text)
Expand Down Expand Up @@ -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
)

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with '<A ' and with many repetitions of '<A '.
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

Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit 0352b27

Please sign in to comment.