const eventRegx = /^(click)(\s)*=|(param)(\s)*=/; const imageAttrReg = /(\s)*src(\s)*=|(\s)*height(\s)*=|(\s)*width(\s)*=|(\s)*align(\s)*=|(\s)*offset(\s)*=|(\s)*click(\s)*=|(\s)*param(\s)*=/; export interface IHtmlTextParserResultObj { text?: string; style?: IHtmlTextParserStack; } export interface IHtmlTextParserStack { color?: string; size?: number; event?: { [k: string]: string }; isNewLine?: boolean; isImage?: boolean; src?: string; imageWidth?: number; imageHeight?: number; imageOffset?: string; imageAlign?: string; underline?: boolean; strikethrough?: boolean; offset?: number; italic?: boolean; bold?: boolean; colorGradient?: { lb: string; rb: string; lt: string; rt: string; }; face?: { color: string; dilate: number; softness: number; }; outline?: { color: string, thickness: number }; underlay?: { color: string; x: number; y: number; dilate: number; softness: number; }; glow?: { color: string; offset: number; inner: number; outer: number; power: number; }; } /** * A utils class for parsing HTML texts. The parsed results will be an object array. */ export class HtmlTextParser { private _specialSymbolArray: Array<[RegExp, string]> = []; private _stack: IHtmlTextParserStack[] = []; private _resultObjectArray: IHtmlTextParserResultObj[] = []; constructor() { this._specialSymbolArray.push([/</g, '<']); this._specialSymbolArray.push([/>/g, '>']); this._specialSymbolArray.push([/&/g, '&']); this._specialSymbolArray.push([/"/g, '"']); this._specialSymbolArray.push([/'/g, '\'']); this._specialSymbolArray.push([/ /g, ' ']); } public parse(htmlString: string) { this._resultObjectArray.length = 0; this._stack.length = 0; let startIndex = 0; const length = htmlString.length; while (startIndex < length) { let tagEndIndex = htmlString.indexOf('>', startIndex); let tagBeginIndex = -1; if (tagEndIndex >= 0) { tagBeginIndex = htmlString.lastIndexOf('<', tagEndIndex); const noTagBegin = tagBeginIndex < (startIndex - 1); if (noTagBegin) { tagBeginIndex = htmlString.indexOf('<', tagEndIndex + 1); tagEndIndex = htmlString.indexOf('>', tagBeginIndex + 1); } } if (tagBeginIndex < 0) { this._stack.pop(); this._processResult(htmlString.substring(startIndex)); startIndex = length; } else { let newStr = htmlString.substring(startIndex, tagBeginIndex); const tagStr = htmlString.substring(tagBeginIndex + 1, tagEndIndex); if (tagStr === '') newStr = htmlString.substring(startIndex, tagEndIndex + 1); this._processResult(newStr); if (tagEndIndex === -1) { // cc.error('The HTML tag is invalid!'); tagEndIndex = tagBeginIndex; } else if (htmlString.charAt(tagBeginIndex + 1) === '/') { this._stack.pop(); } else { this._addToStack(tagStr); } startIndex = tagEndIndex + 1; } } return this._resultObjectArray; } private _attributeToObject(attribute: string) { attribute = attribute.trim(); const obj: IHtmlTextParserStack = {}; let header = /^(color|size)(\s)*=/.exec(attribute); let tagName = ''; let nextSpace = 0; let eventHanlderString = ''; if (header) { tagName = header[0]; attribute = attribute.substring(tagName.length).trim(); if (attribute === '') { return obj; } // parse color nextSpace = attribute.indexOf(' '); switch (tagName[0]) { case 'c': if (nextSpace > -1) { obj.color = attribute.substring(0, nextSpace).trim(); } else { obj.color = attribute; } break; case 's': obj.size = parseInt(attribute); break; default: break; } // tag has event arguments if (nextSpace > -1) { eventHanlderString = attribute.substring(nextSpace + 1).trim(); obj.event = this._processEventHandler(eventHanlderString); } return obj; } header = /^(br(\s)*\/)/.exec(attribute); if (header && header[0].length > 0) { tagName = header[0].trim(); if (tagName.startsWith('br') && tagName[tagName.length - 1] === '/') { obj.isNewLine = true; this._resultObjectArray.push({ text: '', style: { isNewLine: true } as IHtmlTextParserStack }); return obj; } } header = /^(img(\s)*src(\s)*=[^>]+\/)/.exec(attribute); let remainingArgument = ''; let rightQuot = -1; if (header && header[0].length > 0) { tagName = header[0].trim(); if (tagName.startsWith('img') && tagName[tagName.length - 1] === '/') { header = imageAttrReg.exec(attribute); let tagValue: string; let isValidImageTag = false; while (header) { // skip the invalid tags at first attribute = attribute.substring(attribute.indexOf(header[0])); tagName = attribute.substr(0, header[0].length); const originTagNameLength = tagName.length; tagName = tagName.replace(/[^a-zA-Z]/g, '').trim(); tagName = tagName.toLowerCase(); // remove space and = character remainingArgument = attribute.substring(originTagNameLength).trim(); if (tagName === 'src') { rightQuot = this.getRightQuotationIndex(remainingArgument); } else { rightQuot = -1; } nextSpace = remainingArgument.indexOf(' ', rightQuot + 1 >= remainingArgument.length ? -1 : rightQuot + 1); tagValue = (nextSpace > -1) ? remainingArgument.substr(0, nextSpace) : remainingArgument; attribute = remainingArgument.substring(nextSpace).trim(); if (tagValue.endsWith('/')) { tagValue = tagValue.slice(0, -1); } if (tagName === 'src') { switch (tagValue.charCodeAt(0)) { case 34: // " case 39: // ' isValidImageTag = true; tagValue = tagValue.slice(1, -1); break; default: break; } obj.isImage = true; obj.src = tagValue; } else if (tagName === 'height') { obj.imageHeight = parseInt(tagValue); } else if (tagName === 'width') { obj.imageWidth = parseInt(tagValue); } else if (tagName === 'align') { switch (tagValue.charCodeAt(0)) { case 34: // " case 39: // ' tagValue = tagValue.slice(1, -1); break; default: break; } obj.imageAlign = tagValue.toLowerCase(); } else if (tagName === 'offset') { obj.imageOffset = tagValue; } else if (tagName === 'click') { obj.event = this._processEventHandler(`${tagName}=${tagValue}`); } if (obj.event && tagName === 'param') { obj.event[tagName] = tagValue.replace(/^"|"$/g, ''); } header = imageAttrReg.exec(attribute); } if (isValidImageTag && obj.isImage) { this._resultObjectArray.push({ text: '', style: obj }); } return {}; } } header = /^(cg(\s)*[^>]*)/.exec(attribute); if (header) { attribute = header[0].substring("cg".length).trim(); let defaultColorGradientObject = { lb: "#ffffff", rb: "#ffffff", lt: "#ffffff", rt: "#ffffff" }; if (attribute) { let colorGradientAttrReg = /(\s)*lb(\s)*=|(\s)*rb(\s)*=|(\s)*lt(\s)*=|(\s)*rt(\s)*=/; header = colorGradientAttrReg.exec(attribute); let tagValue: string; let remainingArgument; while (header) { //skip the invalid tags at first attribute = attribute.substring(attribute.indexOf(header[0])); tagName = attribute.substr(0, header[0].length); //remove space and = character remainingArgument = attribute.substring(tagName.length).trim(); nextSpace = remainingArgument.indexOf(' '); if (nextSpace > -1) { tagValue = remainingArgument.substr(0, nextSpace); } else { tagValue = remainingArgument; } tagName = tagName.replace(/[^a-zA-Z]/g, "").trim(); tagName = tagName.toLocaleLowerCase(); attribute = remainingArgument.substring(nextSpace).trim(); if (tagName === "lb") { defaultColorGradientObject.lb = tagValue; } else if (tagName === "rb") { defaultColorGradientObject.rb = tagValue; } else if (tagName === "lt") { defaultColorGradientObject.lt = tagValue; } else if (tagName === "rt") { defaultColorGradientObject.rt = tagValue; } header = colorGradientAttrReg.exec(attribute); } } obj.colorGradient = defaultColorGradientObject; } header = /^(face(\s)*[^>]*)/.exec(attribute); if (header) { attribute = header[0].substring("face".length).trim(); let defaultFaceObject = { color: "#ffffff", dilate: 0.5, softness: 0.01 }; if (attribute) { let faceAttrReg = /(\s)*color(\s)*=|(\s)*dilate(\s)*=|(\s)*softness(\s)*=|(\s)*click(\s)*=|(\s)*param(\s)*=/; header = faceAttrReg.exec(attribute); let tagValue: string; let remainingArgument; while (header) { //skip the invalid tags at first attribute = attribute.substring(attribute.indexOf(header[0])); tagName = attribute.substr(0, header[0].length); //remove space and = character remainingArgument = attribute.substring(tagName.length).trim(); nextSpace = remainingArgument.indexOf(' '); if (nextSpace > -1) { tagValue = remainingArgument.substr(0, nextSpace); } else { tagValue = remainingArgument; } tagName = tagName.replace(/[^a-zA-Z]/g, "").trim(); tagName = tagName.toLocaleLowerCase(); attribute = remainingArgument.substring(nextSpace).trim(); if (tagName === "click") { obj.event = this._processEventHandler(`${tagName}=${tagValue}`); } else if (tagName === "color") { defaultFaceObject.color = tagValue; } else if (tagName === "dilate") { defaultFaceObject.dilate = Number(tagValue); } else if (tagName === "softness") { defaultFaceObject.softness = Number(tagValue); } if (obj.event && tagName === 'param') { obj.event.param = tagValue.replace(/^\"|\"$/g, ''); } header = faceAttrReg.exec(attribute); } } obj.face = defaultFaceObject; } header = /^(outline(\s)*[^>]*)/.exec(attribute); if (header) { attribute = header[0].substring('outline'.length).trim(); const defaultOutlineObject = { color: '#ffffff', thickness: 0.1 }; if (attribute) { const outlineAttrReg = /(\s)*color(\s)*=|(\s)*thickness(\s)*=|(\s)*click(\s)*=|(\s)*param(\s)*=/; header = outlineAttrReg.exec(attribute); let tagValue: string; while (header) { // skip the invalid tags at first attribute = attribute.substring(attribute.indexOf(header[0])); tagName = attribute.substr(0, header[0].length); // remove space and = character remainingArgument = attribute.substring(tagName.length).trim(); nextSpace = remainingArgument.indexOf(' '); if (nextSpace > -1) { tagValue = remainingArgument.substr(0, nextSpace); } else { tagValue = remainingArgument; } tagName = tagName.replace(/[^a-zA-Z]/g, '').trim(); tagName = tagName.toLowerCase(); attribute = remainingArgument.substring(nextSpace).trim(); if (tagName === 'click') { obj.event = this._processEventHandler(`${tagName}=${tagValue}`); } else if (tagName === 'color') { defaultOutlineObject.color = tagValue; } else if (tagName === 'thickness') { defaultOutlineObject.thickness = Number(tagValue); } if (obj.event && tagName === 'param') { obj.event[tagName] = tagValue.replace(/^"|"$/g, ''); } header = outlineAttrReg.exec(attribute); } } obj.outline = defaultOutlineObject; } header = /^(underlay(\s)*[^>]*)/.exec(attribute); if (header) { attribute = header[0].substring("underlay".length).trim(); let defaultUnderlayObject = { color: "#ffffff", x: 0, y: 0, dilate: 0.5, softness: 0.1 }; if (attribute) { let underlayAttrReg = /(\s)*color(\s)*=|(\s)*x(\s)*=|(\s)*y(\s)*=|(\s)*dilate(\s)*=|(\s)*softness(\s)*=|(\s)*click(\s)*=|(\s)*param(\s)*=/; header = underlayAttrReg.exec(attribute); let tagValue: string; let remainingArgument; while (header) { //skip the invalid tags at first attribute = attribute.substring(attribute.indexOf(header[0])); tagName = attribute.substr(0, header[0].length); //remove space and = character remainingArgument = attribute.substring(tagName.length).trim(); nextSpace = remainingArgument.indexOf(' '); if (nextSpace > -1) { tagValue = remainingArgument.substr(0, nextSpace); } else { tagValue = remainingArgument; } tagName = tagName.replace(/[^a-zA-Z]/g, "").trim(); tagName = tagName.toLocaleLowerCase(); attribute = remainingArgument.substring(nextSpace).trim(); if (tagName === "click") { obj.event = this._processEventHandler(`${tagName}=${tagValue}`); } else if (tagName === "color") { defaultUnderlayObject.color = tagValue; } else if (tagName === "dilate") { defaultUnderlayObject.dilate = Number(tagValue); } else if (tagName === "softness") { defaultUnderlayObject.softness = Number(tagValue); } else if (tagName === "x") { defaultUnderlayObject.x = Number(tagValue); } else if (tagName === "y") { defaultUnderlayObject.y = Number(tagValue); } if (obj.event && tagName === 'param') { obj.event.param = tagValue.replace(/^\"|\"$/g, ''); } header = underlayAttrReg.exec(attribute); } } obj.underlay = defaultUnderlayObject; } header = /^(glow(\s)*[^>]*)/.exec(attribute); if (header) { attribute = header[0].substring("glow".length).trim(); let defaultGlowObject = { color: "#000000", offset: 0.5, inner: 0.01, outer: 0.01, power: 1 }; if (attribute) { let glowAttrReg = /(\s)*color(\s)*=|(\s)*offset(\s)*=|(\s)*inner(\s)*=|(\s)*outer(\s)*=|(\s)*power(\s)*=|(\s)*click(\s)*=|(\s)*param(\s)*=/; header = glowAttrReg.exec(attribute); let tagValue: string; let remainingArgument; while (header) { //skip the invalid tags at first attribute = attribute.substring(attribute.indexOf(header[0])); tagName = attribute.substr(0, header[0].length); //remove space and = character remainingArgument = attribute.substring(tagName.length).trim(); nextSpace = remainingArgument.indexOf(' '); if (nextSpace > -1) { tagValue = remainingArgument.substr(0, nextSpace); } else { tagValue = remainingArgument; } tagName = tagName.replace(/[^a-zA-Z]/g, "").trim(); tagName = tagName.toLocaleLowerCase(); attribute = remainingArgument.substring(nextSpace).trim(); if (tagName === "click") { obj.event = this._processEventHandler(`${tagName}=${tagValue}`); } else if (tagName === "color") { defaultGlowObject.color = tagValue; } else if (tagName === "offset") { defaultGlowObject.offset = Number(tagValue); } else if (tagName === "inner") { defaultGlowObject.inner = Number(tagValue); } else if (tagName === "outer") { defaultGlowObject.outer = Number(tagValue); } else if (tagName === "power") { defaultGlowObject.power = Number(tagValue); } if (obj.event && tagName === 'param') { obj.event.param = tagValue.replace(/^\"|\"$/g, ''); } header = glowAttrReg.exec(attribute); } } obj.glow = defaultGlowObject; } header = /^(on|u|s|b|i)(\s)*/.exec(attribute); if (header && header[0].length > 0) { tagName = header[0]; attribute = attribute.substring(tagName.length).trim(); switch (tagName[0]) { case 'u': obj.underline = true; obj.offset = attribute[0] === "=" ? Number(attribute.slice(1)) : 0; break; case 's': obj.strikethrough = true; obj.offset = attribute[0] === "=" ? Number(attribute.slice(1)) : 0; break; case 'i': obj.italic = true; break; case 'b': obj.bold = true; break; default: break; } if (attribute === '') { return obj; } obj.event = this._processEventHandler(attribute); } return obj; } // find the right part of the first pair of following quotations. private getRightQuotationIndex(remainingArgument: string) { let leftQuot = -1; let rightQuot = -1; // Skip a pair of quotations for avoiding spaces in image name are detected. const leftSingleQuot = remainingArgument.indexOf('\''); const leftDoubleQuot = remainingArgument.indexOf('"'); const useSingleQuot = leftSingleQuot > -1 && (leftSingleQuot < leftDoubleQuot || leftDoubleQuot === -1); const useDoubleQuot = leftDoubleQuot > -1 && (leftDoubleQuot < leftSingleQuot || leftSingleQuot === -1); if (useSingleQuot) { leftQuot = leftSingleQuot; rightQuot = remainingArgument.indexOf('\'', leftQuot + 1 >= remainingArgument.length ? -1 : leftQuot + 1); } else if (useDoubleQuot) { leftQuot = leftDoubleQuot; rightQuot = remainingArgument.indexOf('"', leftQuot + 1 >= remainingArgument.length ? -1 : leftQuot + 1); } return rightQuot; } private _processEventHandler(eventString: string) { const obj = {}; let index = 0; let isValidTag = false; let eventNames = eventRegx.exec(eventString); while (eventNames) { let eventName = eventNames[0]; let eventValue = ''; isValidTag = false; eventString = eventString.substring(eventName.length).trim(); if (eventString.charAt(0) === '"') { index = eventString.indexOf('"', 1); if (index > -1) { eventValue = eventString.substring(1, index).trim(); isValidTag = true; } index++; } else if (eventString.charAt(0) === '\'') { index = eventString.indexOf('\'', 1); if (index > -1) { eventValue = eventString.substring(1, index).trim(); isValidTag = true; } index++; } else { // skip the invalid attribute value const match = /(\S)+/.exec(eventString); if (match) { eventValue = match[0]; } else { eventValue = ''; } index = eventValue.length; } if (isValidTag) { eventName = eventName.substring(0, eventName.length - 1).trim(); obj[eventName] = eventValue; } eventString = eventString.substring(index).trim(); eventNames = eventRegx.exec(eventString); } return obj; } private _addToStack(attribute: string) { const obj = this._attributeToObject(attribute); if (this._stack.length === 0) { this._stack.push(obj); } else { if (obj.isNewLine || obj.isImage) { return; } // for nested tags const previousTagObj = this._stack[this._stack.length - 1]; for (const key in previousTagObj) { if (!(obj[key])) { obj[key] = previousTagObj[key]; } } this._stack.push(obj); } } private _processResult(value: string) { if (value.length === 0) { return; } value = this._escapeSpecialSymbol(value); if (this._stack.length > 0) { this._resultObjectArray.push({ text: value, style: this._stack[this._stack.length - 1] }); } else { this._resultObjectArray.push({ text: value } as IHtmlTextParserResultObj); } } private _escapeSpecialSymbol(str: string) { for (const symbolArr of this._specialSymbolArray) { const key = symbolArr[0]; const value = symbolArr[1]; str = str.replace(key, value); } return str; } }