diff --git a/README.md b/README.md index 05341c7..2754132 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,39 @@ let ansiFormat = TextFormatter.formatAnsi(formatted, { console.log(deformatted); ``` +`TextFormatter.formatHtml()` + +This is a port of the original [Maniaplanet style parser](https://github.com/maniaplanet/maniaplanet-style-js-parser) written in pure JS. *Copyright © 2012 - 2026 Nadeo & Baptiste Lafontaine* + +```js +let formatted = "$l[https://kc.jfreu.com]$F00T$D01M$C13U$A14.$815K$727r$528a$329z$23By$03CC$03Co$04Bl$059o$068r$077s$085$l $094v$0A30$0B1.$0C01"; +let htmlFormat = TextFormatter.formatHtml(formatted); +document.querySelector("#result").innerHTML = htmlFormat; +``` + +You can also pass some options in it: + +- `disableLinks`: will disable links to external sites (false by default) +- `lightBackground`: adapt color to be visible on light backgrounds (false by default) +- `linkTarget`: add a `target` attribute into links + +```js +let formatted = "$l[https://kc.jfreu.com]$F00T$D01M$C13U$A14.$815K$727r$528a$329z$23By$03CC$03Co$04Bl$059o$068r$077s$085$l $094v$0A30$0B1.$0C01"; +let htmlFormat = TextFormatter.formatHtml(formatted, { + disableLinks: true, + lightBackground: true +}); +document.querySelector("#result").innerHTML = htmlFormat; +``` + +```js +let formatted = "$l[https://greep.fr]Open this link to a new tab$l with the target attribute"; +let htmlFormat = TextFormatter.formatHtml(formatted, { + linkTarget: "_blank" +}); +document.querySelector("#result").innerHTML = htmlFormat; +``` + ## Time formatting `Time.time` - Total milliseconds as integer value. diff --git a/package.json b/package.json index ef9c581..f1584be 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,11 @@ "name": "ThaumicTom", "url": "https://thaumictom.de", "email": "tom@thaumictom.de" + }, + { + "name": "Baptiste Lafontaine", + "url": "https://magnetik.org", + "email": "baptiste@nadeo.com" } ], "license": "MIT", diff --git a/src/TextFormatter.js b/src/TextFormatter.js index b9f86a4..a0e9824 100644 --- a/src/TextFormatter.js +++ b/src/TextFormatter.js @@ -134,6 +134,300 @@ class TextFormatter { return { output, bold, italic, uppercase, colored, link }; } + /** + * Colored Style bit flag + * @type {number} + * @private + */ + static #StyleColored = 0x1000; + + /** + * Italic Style bit flag + * @type {number} + * @private + */ + static #StyleItalic = 0x2000; + + /** + * Bold Style bit flag + * @type {number} + * @private + */ + static #StyleBold = 0x4000; + + /** + * Shadowed Style bit flag + * @type {number} + * @private + */ + static #StyleShadowed = 0x8000; + + /** + * Wide Style bit flag + * @type {number} + * @private + */ + static #StyleWide = 0x20000; + + /** + * Narrow Style bit flag + * @type {number} + * @private + */ + static #StyleNarrow = 0x40000; + + // Credits to @magnetik for the original HTML parser + /** + * Format the text into HTML format + * @param {string} input + * @param {{disableLinks?: boolean, lightBackground?: boolean, linkTarget?: string}} [options] + * @returns {string} + */ + static formatHtml(input, options = {}) { + let isCode = false, + isQuickLink = false, + isPrettyLink = false, + color = null; + + let style = 0, + tokens = [], + styleStack = [], + nextToken = { style: 0, text: "" }, + nextLinkToken = null, + linkLevel = 0; + + const endLink = () => { + if (nextToken.text !== "") { + tokens.push(nextToken); + nextToken = { style, text: "" }; + tokens.push({ type: "linkEnd" }); + } else if (tokens[tokens.length - 1] === nextLinkToken) { + tokens.pop(); + } else { + tokens.push({ type: "linkEnd" }); + } + + nextLinkToken = null; + isQuickLink = false; + isPrettyLink = false; + }; + + const endText = (force = false) => { + if (force || style !== nextToken.style) { + if (nextToken.text !== "") { + tokens.push(nextToken); + nextToken = { style, text: "" }; + } else { + nextToken.style = style; + } + } + }; + + for (const c of input) { + if (isCode) { + const tok = c.toLowerCase(); + switch (tok) { + case "i": + style ^= this.#StyleItalic; + break; + case "o": + style ^= this.#StyleBold; + break; + case "s": + style ^= this.#StyleShadowed; + break; + case "w": + style |= this.#StyleWide; + style &= ~this.#StyleNarrow; + break; + case "n": + style |= this.#StyleNarrow; + style &= ~this.#StyleWide; + break; + case "l": + case "h": + case "p": + if (nextLinkToken) { + endLink(); + } else { + endText(true); + nextLinkToken = { type: "link", manialink: tok === "h", link: "", target: options.linkTarget }; + if (!options.disableLinks) { + tokens.push(nextLinkToken); + } + isQuickLink = true; + isPrettyLink = true; + linkLevel = styleStack.length; + } + break; + case "z": + style = styleStack.length === 0 ? 0 : styleStack[styleStack.length - 1]; + if (nextLinkToken) { + endLink(); + } + break; + case "m": + style &= ~(this.#StyleNarrow | this.#StyleWide); + break; + case "g": + style &= styleStack.length === 0 ? ~0x1fff : (styleStack[styleStack.length - 1] | ~0x1fff); + break; + case "<": + styleStack.push(style); + break; + case ">": + if (styleStack.length !== 0) { + style = styleStack.pop(); + if (nextLinkToken && linkLevel > styleStack.length) { + endLink(); + } + } + break; + case "$": + nextToken.text += "$"; + break; + default: + if (/[a-f0-9]/i.test(c)) { + color = c; + } + } + endText(); + isCode = false; + } else if (c === "$") { + isCode = true; + if (isQuickLink && isPrettyLink) { + isPrettyLink = false; + } + } else if (color) { + let endColor = false, + addChar = false; + + if (/[a-f0-9]/i.test(c)) { + color += c.replace(/[^a-f0-9]/gi, "0"); + endColor = color.length === 3; + } else { + color += "0".repeat(3 - color.length); + endColor = true; + addChar = true; + } + + if (endColor) { + if (options.lightBackground) { + color = this.#invertLight(color); + } + style &= ~0xfff; + style |= this.#StyleColored | (parseInt(color, 16) & 0xfff); + endText(); + color = null; + + if (addChar) { + nextToken.text += c; + } + } + } else if (isQuickLink && isPrettyLink) { + if (c === "[") { + isQuickLink = false; + } else { + isPrettyLink = false; + nextToken.text += c; + nextLinkToken.link += c; + } + } else if (isPrettyLink) { + if (c === "]") { + isPrettyLink = false; + } else { + nextLinkToken.link += c; + } + } else { + nextToken.text += c; + if (isQuickLink) { + nextLinkToken.link += c; + } + } + } + + if (nextToken.text !== "") { + tokens.push(nextToken); + } + + if (nextLinkToken && !options.disableLinks) { + tokens.push({ type: "linkEnd" }); + } + + return tokens.map((token) => this.#tokenToHtml(token)).join(""); + } + + /** + * Convert a token to HTML + * @param {{type?: string, style?: number, text?: string, manialink?: boolean, link?: string}} token + * @returns {string} + * @private + */ + static #tokenToHtml(token) { + if (token.type === "link") { + return this.#linkToHtml(token); + } + if (token.type === "linkEnd") { + return ""; + } + const styleStack = []; + if (token.style) { + if (token.style & this.#StyleColored) { + let color = this.#colorRgb12to24(token.style & 0xfff).toString(16); + if (color.length === 1) { + color = "00000" + color; + } else if (color.length === 2) { + color = "0000" + color; + } else if (color.length === 4) { + color = "00" + color; + } + styleStack.push(`color: #${color};`); + } + if (token.style & this.#StyleItalic) { + styleStack.push("font-style:italic;"); + } + if (token.style & this.#StyleBold) { + styleStack.push("font-weight:bold;"); + } + if (token.style & this.#StyleShadowed) { + styleStack.push("text-shadow:1px 1px 1px rgba(0, 0, 0, 0.5);"); + } + if (token.style & this.#StyleWide) { + styleStack.push("letter-spacing:.1em;font-size:105%;"); + } else if (token.style & this.#StyleNarrow) { + styleStack.push("letter-spacing:-.1em;font-size:95%;"); + } + return `${token.text}`; + } + return token.text; + } + + /** + * Convert a link token to HTML + * @param {{manialink: boolean, link: string, target?: string}} token + * @returns {string} + * @private + */ + static #linkToHtml(token) { + if (token.manialink && !/^maniaplanet:/i.test(token.link)) { + token.link = "maniaplanet://#manialink=" + token.link; + } + if (!token.manialink && !/^https?:/i.test(token.link)) { + token.link = "http://" + token.link; + } + return ``; + } + + /** + * Convert a 12 bit color to a 24 bit color + * @param {number} color + * @returns {number} + * @private + */ + static #colorRgb12to24(color) { + return (color & 0xf00) * 0x1100 + (color & 0xf0) * 0x110 + (color & 0xf) * 0x11; + } + /** * Darken a light color for a light background * @param {string} hexColor diff --git a/test/TextFormat.test.js b/test/TextFormat.test.js index f604947..f9152e5 100644 --- a/test/TextFormat.test.js +++ b/test/TextFormat.test.js @@ -139,4 +139,50 @@ describe("Text Formatting", function(){ ) ); }); + + it("should format text to HTML", function(){ + assert.equal("foo", tmessentials.TextFormatter.formatHtml("foo")); + assert.equal('tag', tmessentials.TextFormatter.formatHtml("$otag")); + assert.equal("hi there", tmessentials.TextFormatter.formatHtml("$uhi there")); + assert.equal('Red', tmessentials.TextFormatter.formatHtml("$f00Red")); + assert.equal('Red', tmessentials.TextFormatter.formatHtml("$fRed")); + }); + + it("should format links to HTML", function(){ + assert.equal( + 'trackmania.com', + tmessentials.TextFormatter.formatHtml("$l[http://maniaplanet.com]trackmania.com$l") + ); + assert.equal( + 'http://maniaplanet.com', + tmessentials.TextFormatter.formatHtml("$lhttp://maniaplanet.com$l") + ); + assert.equal( + 'http://maniaplanet.com', + tmessentials.TextFormatter.formatHtml("$lhttp://maniaplanet.com") + ); + assert.equal("", tmessentials.TextFormatter.formatHtml("$l[www.clan-nuitblanche.org]$fff$l")); + assert.equal( + 'maniaplanet', + tmessentials.TextFormatter.formatHtml("$l[maniaplanet.com]maniaplanet$l") + ); + assert.equal( + 'maniaplanet', + tmessentials.TextFormatter.formatHtml("$l[https://maniaplanet.com]maniaplanet$l") + ); + assert.equal( + 'maniaplanet', + tmessentials.TextFormatter.formatHtml("$l[maniaplanet.com]maniaplanet$l", { linkTarget: "_blank" }) + ); + assert.equal( + 'ManiaFlash', + tmessentials.TextFormatter.formatHtml("$h[maniaflash]ManiaFlash$h") + ); + }); + + it("should format text to HTML with options", function(){ + assert.equal("maniaplanet.com", tmessentials.TextFormatter.formatHtml("$lmaniaplanet.com", { disableLinks: true })); + assert.equal("Maniaplanet", tmessentials.TextFormatter.formatHtml("$l[maniaplanet.com]Maniaplanet", { disableLinks: true })); + assert.equal('Text', tmessentials.TextFormatter.formatHtml("$fffText", { lightBackground: true })); + }); }); \ No newline at end of file