text-annotator-v2
Version:
A JavaScript library for locating and annotating plain text in HTML
310 lines (281 loc) • 8.49 kB
text/typescript
type Location = {
index: number,
length: number
}
type Tag = Location & {
isCloseTag?: boolean,
annotationIndex?: number
}
type SearchOptions = {
prefix?: string,
postfix?: string,
trim?: boolean,
caseSensitive?: boolean
offset?: number
}
type AnnotateOptions = {
tagName?: string,
baseClassName?: string,
classPattern?: string
}
class TextAnnotator {
private html = ''
private text = ''
private tags: Tag[] = []
private annotations: Location[] = []
constructor(html: string) {
this.html = html
const { text, tags } = this._stripHTMLTags(html)
this.text = text
this.tags = tags
}
search(
searchText: string,
{
prefix = '',
postfix = '',
trim = true,
caseSensitive = false,
offset = 0,
}: SearchOptions = {}
): number {
const { text, annotations } = this
let str = prefix + searchText + postfix
str = trim ? str.trim() : str
str = caseSensitive ? str : str.toLowerCase()
const index = (caseSensitive ? text : text.toLowerCase()).indexOf(
str,
offset
)
const prefixLength = trim
? prefix.replace(/^\s+/, '').length
: prefix.length
const postfixLength = trim
? postfix.replace(/\s+$/, '').length
: postfix.length
return index === -1
? -1
: annotations.push({
index: index + prefixLength,
length: str.substring(prefixLength, str.length - postfixLength)
.length,
}) - 1
}
searchAll(searchText: string, options: SearchOptions): number[] {
let offset = 0
const annotationIndexes = []
let annotationIndex = -1
// do not mutate param
const newOptions = Object.assign({}, options)
do {
annotationIndex = this.search(searchText, newOptions)
if (annotationIndex !== -1) {
offset = this.annotations[annotationIndex].index + 1
newOptions.offset = offset
annotationIndexes.push(annotationIndex)
}
} while (annotationIndex !== -1)
return annotationIndexes
}
annotate(
annotationIndex: number,
{
tagName = 'span',
baseClassName = 'annotation',
classPattern = 'annotation-',
}: AnnotateOptions = {}
): string {
const { tags, annotations, _insert, _binaryInsert } = this
const annotation = annotations[annotationIndex]
// [start, end, offset]
const annotationLocation = [
annotation.index,
annotation.index + annotation.length,
0,
]
// partition
const annotatorLocations = [[...annotationLocation]]
for (let i = 0; i < tags.length; i++) {
const { index: tagIndex, length: tagLength } = tags[i]
if (tagIndex <= annotationLocation[0]) {
annotatorLocations[0][2] += tagLength
} else if (tagIndex < annotationLocation[1]) {
const lastTagIndex = i === 0 ? 0 : tags[i - 1].index
const lastAnnotatorLocation =
annotatorLocations[annotatorLocations.length - 1]
if (tagIndex === lastTagIndex) {
lastAnnotatorLocation[2] += tagLength
} else {
const annotatorLocationEnd = lastAnnotatorLocation[1]
lastAnnotatorLocation[1] = tagIndex
annotatorLocations.push([
lastAnnotatorLocation[1],
annotatorLocationEnd,
lastAnnotatorLocation[2] + tagLength,
])
}
} else {
break
}
}
// insert annotator tags into tag list and html
const annotatorOpenTag = `<${tagName} class="${baseClassName} ${classPattern}${annotationIndex}">`
const annotatorCloseTag = `</${tagName}>`
const annotatorOpenTagLength = annotatorOpenTag.length
const annotatorCloseTagLength = annotatorCloseTag.length
let locInc = 0
for (let i = 0; i < annotatorLocations.length; i++) {
const annotatorLocation = annotatorLocations[i]
_binaryInsert(
tags,
{
index: annotatorLocation[0],
length: annotatorOpenTagLength,
isCloseTag: false,
annotationIndex,
},
(a: {index: number}, b: {index: number}) => a.index <= b.index ? -1 : 1
)
_binaryInsert(
tags,
{
index: annotatorLocation[1],
length: annotatorCloseTagLength,
isCloseTag: true,
annotationIndex,
},
(a: {index: number}, b: {index: number}) => a.index - b.index
)
this.html = _insert(
this.html,
annotatorOpenTag,
annotatorLocation[0] + annotatorLocation[2] + locInc
)
this.html = _insert(
this.html,
annotatorCloseTag,
annotatorLocation[1] +
annotatorLocation[2] +
locInc +
annotatorOpenTagLength
)
locInc += annotatorOpenTagLength + annotatorCloseTagLength
}
return this.html
}
annotateAll(annotationIndexes: number[], options: AnnotateOptions): string {
annotationIndexes.forEach((annotationIndex) => {
this.annotate(annotationIndex, options)
})
return this.html
}
unannotate(annotationIndex: number): string {
// annotatorIndexesInTags amd annotators have the same size
const annotatorIndexesInTags: number[] = []
const annotators = this.tags.filter((tag, index) => {
if (tag.annotationIndex === annotationIndex) {
annotatorIndexesInTags.push(index)
}
return tag.annotationIndex === annotationIndex
})
const otherTags = this.tags.filter(
(tag) => tag.annotationIndex !== annotationIndex
)
// find index difference
for (let i = 0; i < annotators.length; i++) {
const annotator = annotators[i]
let indexInc = 0
for (let j = 0; j < otherTags.length; j++) {
const otherTag = otherTags[j]
if (annotator.index < otherTag.index) {
break
} else if (annotator.index > otherTag.index) {
indexInc += otherTag.length
} else {
if (otherTag.annotationIndex === undefined) {
if (!annotator.isCloseTag) {
indexInc += otherTag.length
}
} else {
if (annotator.annotationIndex < otherTag.annotationIndex) {
if (otherTag.isCloseTag) {
indexInc += otherTag.length
}
} else {
if (!annotator.isCloseTag) {
indexInc += otherTag.length
}
}
}
}
}
// remove annotators one by one
this.html =
this.html.slice(0, annotator.index + indexInc) +
this.html.slice(annotator.index + indexInc + annotator.length)
this.tags.splice(annotatorIndexesInTags[i] - i, 1)
}
return this.html
}
unannotateAll(annotationIndexes: number[]): string {
annotationIndexes.forEach((annotationIndex) => {
this.unannotate(annotationIndex)
})
return this.html
}
// pure function
_stripHTMLTags(html: string): { text: string, tags: Tag[] } {
let text = html
const tags = []
// elaborate it later
let tag: RegExpMatchArray
const tagRegEx = /<[^>]+>/
while ((tag = text.match(tagRegEx))) {
text = text.replace(tag[0], '')
tags.push({
index: tag.index,
length: tag[0].length,
isCloseTag: tag[0].startsWith('</'),
})
}
return { text, tags }
}
// pure function
_insert(str1: string, str2: string, index: number): string {
return str1.slice(0, index) + str2 + str1.slice(index)
}
// pure function
_binaryInsert(arr: unknown[], val: unknown, comparator: (a: unknown, b: unknown) => number) {
if (arr.length === 0 || comparator(arr[0], val) >= 0) {
arr.splice(0, 0, val)
return arr
} else if (arr.length > 0 && comparator(arr[arr.length - 1], val) <= 0) {
arr.splice(arr.length, 0, val)
return arr
}
let left = 0,
right = arr.length
let leftLast = 0,
rightLast = right
while (left < right) {
const inPos = Math.floor((right + left) / 2)
const compared = comparator(arr[inPos], val)
if (compared < 0) {
left = inPos
} else if (compared > 0) {
right = inPos
} else {
right = inPos
left = inPos
}
if (leftLast === left && rightLast === right) {
break
}
leftLast = left
rightLast = right
}
arr.splice(right, 0, val)
return arr
}
}
export default TextAnnotator