From 3e7fcbfe5e20ebfad5234c011443ef4bb6e364de Mon Sep 17 00:00:00 2001 From: ncpa0cpl Date: Mon, 27 Apr 2026 17:55:06 +0200 Subject: [PATCH] fix: incorrect line length detection if markup contained html entities, unexpected spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit if the formatted text contained html entities like `<` and `>` they would be counted as 4 characters even though those are later replaced with `<` and `>` characters respectively. this fixes that by performing replacement earlier, before length detection happens. Input: ```html <!> ``` Old output: ``` ┌──────────┐ │ │ └──────────┘ ``` New Output: ``` ┌──────────┐ │ │ └──────────┘ ``` in some cases when an empty inline tag was followed by a whitespace followed by a non empty inline tag starting with whitespace, that line after formatting would start with a single whitespace. Input: ```html First Line Second Line ``` Old Output: ``` First Line Second Line ``` New Output: ``` First Line Second Line ``` --- .../__snapshots__/formatter.test.ts.snap | 18 +++- __tests__/formatter/formatter.test.ts | 44 ++++++++- src/formatter/formatter.ts | 98 ++++++++++++++----- src/formatter/text-renderer/text.ts | 3 + 4 files changed, 136 insertions(+), 27 deletions(-) diff --git a/__tests__/formatter/__snapshots__/formatter.test.ts.snap b/__tests__/formatter/__snapshots__/formatter.test.ts.snap index bf65168..7c9e788 100644 --- a/__tests__/formatter/__snapshots__/formatter.test.ts.snap +++ b/__tests__/formatter/__snapshots__/formatter.test.ts.snap @@ -165,6 +165,20 @@ exports[`MarkupFormatter tag scenario 20 1`] = ` └───────┘" `; +exports[`MarkupFormatter tag scenario 21 1`] = ` +"┌─────────────┐ +│< not-a-tag >│ +└─────────────┘" +`; + +exports[`MarkupFormatter tag scenario 22 1`] = ` +"┌─────────────┐ +│First Line │ +│ │ +│Second Line │ +└─────────────┘" +`; + exports[`MarkupFormatter
    tag should correctly render list with numbered indexes 1`] = ` "1. Red 2. Green @@ -387,11 +401,11 @@ exports[`MarkupFormatter should correctly format the xml scenario 03 1`] = ` Normal" `; -exports[`MarkupFormatter should correctly format the xml scenario 04 1`] = `" Red   Green "`; +exports[`MarkupFormatter should correctly format the xml scenario 04 1`] = `" Red  Green "`; exports[`MarkupFormatter should correctly format the xml scenario 05 1`] = `"Lorem ipsum dolor sit amet consectetur adipiscing"`; -exports[`MarkupFormatter should correctly format the xml scenario 06 1`] = `"Green Blue Orange"`; +exports[`MarkupFormatter should correctly format the xml scenario 06 1`] = `"Green Blue Orange"`; exports[`MarkupFormatter should correctly format the xml scenario 07 1`] = `"Lorem ipsum dolor sit amet"`; diff --git a/__tests__/formatter/formatter.test.ts b/__tests__/formatter/formatter.test.ts index 22c00a6..9d0b361 100644 --- a/__tests__/formatter/formatter.test.ts +++ b/__tests__/formatter/formatter.test.ts @@ -113,7 +113,7 @@ describe("MarkupFormatter", () => { const formatted = MarkupFormatter.format(xml); - expect(formatted).toMatchAnsiString(" Red Green "); + expect(formatted).toMatchAnsiString(" Red Green "); expect(formatted).toContainAnsiStringWithStyles(" Red ", { color: "red", bold: true, @@ -214,14 +214,14 @@ describe("MarkupFormatter", () => { const formatted = MarkupFormatter.format(xml); - expect(formatted).toMatchAnsiString("Green Blue Orange"); + expect(formatted).toMatchAnsiString("Green Blue Orange"); expect(formatted).toContainAnsiStringWithStyles("Green", { color: "rgb(137, 245, 209)", }); expect(formatted).toContainAnsiStringWithStyles("Blue", { color: "blue", }); - expect(formatted).toContainAnsiStringWithStyles(" Orange", { + expect(formatted).toContainAnsiStringWithStyles(" Orange", { color: "#f5aa42", }); expect(formatted).toMatchSnapshot(); @@ -2202,6 +2202,44 @@ Other text ); expect(formatted).toMatchSnapshot(); }); + + it("scenario 21", () => { + const xml = html` < not-a-tag > `; // effective width of 12 + + const formatted = MarkupFormatter.format(xml); + + expect(formatted).toMatchAnsiString( + ` +┌─────────────┐ +│< not-a-tag >│ +└─────────────┘ + `.trim() + ); + expect(formatted).toMatchSnapshot(); + }); + + it("scenario 22", () => { + const xml = html` + First Line
    + + + Second Line + + `; + + const formatted = MarkupFormatter.format(xml); + + expect(formatted).toMatchAnsiString( + ` +┌─────────────┐ +│First Line │ +│ │ +│Second Line │ +└─────────────┘ + `.trim() + ); + expect(formatted).toMatchSnapshot(); + }); }); describe("complex structures scenarios", () => { diff --git a/src/formatter/formatter.ts b/src/formatter/formatter.ts index d3a8c25..e38e7c3 100644 --- a/src/formatter/formatter.ts +++ b/src/formatter/formatter.ts @@ -1,6 +1,5 @@ import { TermxBgColors } from "../colors/termx-bg-color"; import { TermxFontColors } from "../colors/termx-font-colors"; -import { desanitizeHtml } from "../html-tag"; import type { MarkupNode } from "../markup-parser"; import { parseMarkup } from "../markup-parser"; import type { Settings } from "../settings"; @@ -127,11 +126,12 @@ export class MarkupFormatter { format(markup: string | MarkupNode): string { const node = typeof markup === "string" ? parseMarkup(markup) : markup; - const text = this.parse(node, new TextRenderer()); + this.normalizeNode(node); + const text = this.putInto(node, new TextRenderer()); text.removeTrailingNewLine(); - return desanitizeHtml(text.render()); + return text.render(); } private handleError(message: string) { @@ -162,8 +162,40 @@ export class MarkupFormatter { return result; } - private normalizeNode(node: MarkupNode) { + private getPreNodeLastLine(node: MarkupNode, initLine: string): string { + let currentLine = initLine; + + if ( + node.tag === "br" || + node.tag == "li" || + BLOCK_NODE_TYPES.includes(node.tag) + ) { + return ""; + } + + for (let i = 0; i < node.content.length; i++) { + const content = node.content[i]!; + + if (typeof content === "string") { + const lastEolIdx = content.lastIndexOf("\n"); + if (lastEolIdx === -1) { + currentLine += content; + } else { + currentLine = content.substring(lastEolIdx + 1); + } + } else { + currentLine = this.getPreNodeLastLine(content, currentLine); + } + } + + return currentLine; + } + + private normalizeNode(node: MarkupNode, initLine = ""): string { + let currentLine = initLine; + if (node.tag !== "pre") { + // remove unnecessary whitespace for (let i = 0; i < node.content.length; i++) { const content = node.content[i]!; @@ -175,18 +207,19 @@ export class MarkupFormatter { const prevTag = getNodeType(node.content[i - 1]); const nextTag = getNodeType(node.content[i + 1]); - const isStartOfLine = - prevNodeDisplayType === "block" || prevTag === "br" || i === 0; + const isStartOfLine = currentLine.trim() === ""; const isEndOfLine = nextNodeDisplayType === "block" || nextTag === "br" || i === node.content.length - 1; if (trimmed.length === 0) { - const shouldKeepWhitespace = !isStartOfLine && !isEndOfLine; + const shouldKeepWhitespace = + !isStartOfLine && !isEndOfLine && currentLine.at(-1) != " "; if (shouldKeepWhitespace && prevTag !== "s") { node.content[i] = " "; + currentLine += " "; } else { // Remove the empty node node.content = node.content @@ -198,6 +231,7 @@ export class MarkupFormatter { if ( !isStartOfLine && singleLined[0] === " " && + currentLine.at(-1) != " " && prevNodeDisplayType === "inline" ) { trimmed = " " + trimmed; @@ -205,12 +239,18 @@ export class MarkupFormatter { if (!isEndOfLine && singleLined[singleLined.length - 1] === " ") { node.content[i] = trimmed + " "; + currentLine += trimmed + " "; } else { node.content[i] = trimmed; + currentLine += trimmed; } } + } else { + currentLine = this.normalizeNode(content, currentLine); } } + } else { + currentLine = this.getPreNodeLastLine(node, currentLine); } for (let i = 0; i < node.content.length; i++) { @@ -234,13 +274,27 @@ export class MarkupFormatter { } } } + + if ( + node.tag === "br" || + node.tag === "li" || + BLOCK_NODE_TYPES.includes(node.tag) + ) { + currentLine = ""; + } + + return currentLine; } - private parse(node: MarkupNode, result: TextRenderer): TextRenderer { + /** + * Takes a MarkupNode and puts all of its contents into the + * TextRenderer + */ + private putInto(node: MarkupNode, result: TextRenderer): TextRenderer { switch (node.tag) { case "pre": { ScopeTracker.enterScope(this.createScope(node)); - this.normalizeNode(node); + // this.normalizeNode(node); const charGroup = new CharacterGroup( ScopeTracker.currentScope.attributes @@ -265,7 +319,7 @@ export class MarkupFormatter { case "line": case "span": { ScopeTracker.enterScope(this.createScope(node)); - this.normalizeNode(node); + // this.normalizeNode(node); const charGroup = new CharacterGroup( ScopeTracker.currentScope.attributes @@ -277,7 +331,7 @@ export class MarkupFormatter { if (typeof content === "string") { result.appendText(charGroup.createChars(content)); } else { - this.parse(content, result); + this.putInto(content, result); } } @@ -291,7 +345,7 @@ export class MarkupFormatter { const padding = this.getListPadding(); ScopeTracker.enterScope(this.createScope(node)); - this.normalizeNode(node); + // this.normalizeNode(node); const unstyledCharGroup = new CharacterGroup({ bg: ScopeTracker.currentScope.attributes.bg, @@ -325,7 +379,7 @@ export class MarkupFormatter { i + 1 ); - const subText = this.parse(content, new TextRenderer()); + const subText = this.putInto(content, new TextRenderer()); subText.prependAllLines( unstyledCharGroup.createChars(" ".repeat(contentPad)) @@ -350,7 +404,7 @@ export class MarkupFormatter { } case "li": { ScopeTracker.enterScope(this.createScope(node)); - this.normalizeNode(node); + // this.normalizeNode(node); const charGroup = new CharacterGroup( ScopeTracker.currentScope.attributes @@ -366,7 +420,7 @@ export class MarkupFormatter { if (typeof content === "string") { result.appendText(charGroup.createChars(content)); } else { - this.parse(content, result); + this.putInto(content, result); } } @@ -376,7 +430,7 @@ export class MarkupFormatter { } case "pad": { ScopeTracker.enterScope(this.createScope(node)); - this.normalizeNode(node); + // this.normalizeNode(node); const charGroup = new CharacterGroup( ScopeTracker.currentScope.attributes @@ -392,7 +446,7 @@ export class MarkupFormatter { if (typeof content === "string") { contentText.appendText(charGroup.createChars(content)); } else { - this.parse(content, contentText); + this.putInto(content, contentText); } } @@ -407,7 +461,7 @@ export class MarkupFormatter { } case "frame": { ScopeTracker.enterScope(this.createScope(node)); - this.normalizeNode(node); + // this.normalizeNode(node); const charGroup = new CharacterGroup( ScopeTracker.currentScope.attributes @@ -459,7 +513,7 @@ export class MarkupFormatter { if (content.length > 0) contentText.appendText(charGroup.createChars(content)); } else { - this.parse(content, contentText); + this.putInto(content, contentText); } } @@ -603,7 +657,7 @@ export class MarkupFormatter { } case "s": { ScopeTracker.enterScope(this.createScope(node)); - this.normalizeNode(node); + // this.normalizeNode(node); const charGroup = new CharacterGroup( ScopeTracker.currentScope.attributes @@ -616,7 +670,7 @@ export class MarkupFormatter { return result; } case "": { - this.normalizeNode(node); + // this.normalizeNode(node); const charGroup = result.lastGroup ?? @@ -628,7 +682,7 @@ export class MarkupFormatter { if (typeof content === "string") { result.appendText(charGroup.createChars(content)); } else { - this.parse(content, result); + this.putInto(content, result); } } diff --git a/src/formatter/text-renderer/text.ts b/src/formatter/text-renderer/text.ts index b457ca3..b6195f4 100644 --- a/src/formatter/text-renderer/text.ts +++ b/src/formatter/text-renderer/text.ts @@ -1,5 +1,6 @@ import { TermxBgColors } from "../../colors/termx-bg-color"; import { TermxFontColors } from "../../colors/termx-font-colors"; +import { desanitizeHtml } from "../../html-tag"; import { terminalWidth } from "../../terminal-width"; import type { Styles } from "./styles"; @@ -61,6 +62,8 @@ export class CharacterGroup { constructor(public readonly styles: Styles) {} createChars(value: string): Character[] { + value = desanitizeHtml(value); + const chars: Character[] = []; for (let i = 0; i < value.length; i++) {