From 360347e8f6b5c4fa20ee891241b135b55a5da9d7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:10:42 +0100 Subject: [PATCH 1/2] fix(charts): isolate repeated title markup stripping Recover the focused change from 1856722 without frontend upgrades. Validated four text utility tests with Bun 1.4.3. --- frontend/src/components/AnalysisChart.tsx | 3 ++- frontend/src/figure/charts.ts | 3 ++- frontend/src/utils/text.test.ts | 24 +++++++++++++++++++++++ frontend/src/utils/text.ts | 17 ++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 frontend/src/utils/text.test.ts diff --git a/frontend/src/components/AnalysisChart.tsx b/frontend/src/components/AnalysisChart.tsx index 3e40f157..17e8a437 100644 --- a/frontend/src/components/AnalysisChart.tsx +++ b/frontend/src/components/AnalysisChart.tsx @@ -1,13 +1,14 @@ // © 2026 Joshua Benjamin Jewell. All rights reserved. // Licensed under the GNU Affero General Public License version 3 (AGPLv3). import Plotly from 'plotly.js-dist-min' +import { stripHtmlTags } from '../utils/text' import { AddToReport } from './AddToReport' import { PlotlyChart } from './PlotlyChart' const figureTitle = (fig: unknown): string | null => { const t = (fig as { layout?: { title?: unknown } })?.layout?.title const text = typeof t === 'string' ? t : (t as { text?: string } | undefined)?.text - return text ? text.replace(/<[^>]+>/g, '') : null + return text ? stripHtmlTags(text) : null } // The chart as standalone SVG, at a fixed print width. diff --git a/frontend/src/figure/charts.ts b/frontend/src/figure/charts.ts index 29845147..6ee30a69 100644 --- a/frontend/src/figure/charts.ts +++ b/frontend/src/figure/charts.ts @@ -2,6 +2,7 @@ // Licensed under the GNU Affero General Public License version 3 (AGPLv3). import { api } from '../api/client' import { extractAlphaPanel } from '../components/alphaMetrics' +import { stripHtmlTags } from '../utils/text' import { textWidth } from './legend' import { PX_PER_PT, tickPt, titlePt, type AlphaMetricKey, type ChartSpec, type FigureDoc, type FigurePane } from './types' @@ -119,7 +120,7 @@ export function paneFigure(raw: unknown, pane: FigurePane, doc: FigureDoc, opts: // A title wider than its pane shrinks to fit, clear of the group letter. const titleRoom = opts.widthPx - 2 * doc.letters.sizePt * PX_PER_PT const titleSize = Math.min(titlePt(doc) * PX_PER_PT, - titlePt(doc) * PX_PER_PT * titleRoom / Math.max(1, textWidth(title.replace(/<[^>]+>/g, ''), titlePt(doc) * PX_PER_PT, family))) + titlePt(doc) * PX_PER_PT * titleRoom / Math.max(1, textWidth(stripHtmlTags(title), titlePt(doc) * PX_PER_PT, family))) for (const k of axisKeys(L, 'x').concat(axisKeys(L, 'y'))) { const ax = { ...(L[k] as Record) } diff --git a/frontend/src/utils/text.test.ts b/frontend/src/utils/text.test.ts new file mode 100644 index 00000000..f5ed71b9 --- /dev/null +++ b/frontend/src/utils/text.test.ts @@ -0,0 +1,24 @@ +// © 2026 Joshua Benjamin Jewell. All rights reserved. +// Licensed under the GNU Affero General Public License version 3 (AGPLv3). +import { describe, expect, test } from 'bun:test' +import { splitLines, stripHtmlTags } from './text' + +describe('stripHtmlTags', () => { + test('removes the markup Plotly allows in a title', () => { + expect(stripHtmlTags('CO2 by run')).toBe('CO2 by run') + }) + + test('a tag hiding inside another does not survive', () => { + expect(stripHtmlTags('ipt>alert(1)')).not.toMatch(/<[^>]+>/) + }) + + test('text without markup, including a bare >, is unchanged', () => { + expect(stripHtmlTags('p > 0.05 for all')).toBe('p > 0.05 for all') + }) +}) + +describe('splitLines', () => { + test('one trimmed value per line, blanks dropped', () => { + expect(splitLines(' a\n\n b \nc')).toEqual(['a', 'b', 'c']) + }) +}) diff --git a/frontend/src/utils/text.ts b/frontend/src/utils/text.ts index dc076655..ffdecc9c 100644 --- a/frontend/src/utils/text.ts +++ b/frontend/src/utils/text.ts @@ -9,3 +9,20 @@ export function splitLines(text: string): string[] { return text.split('\n').map(v => v.trim()).filter(v => v.length > 0) } + +/** + * Strip the pseudo-HTML markup Plotly accepts in titles and axis labels (, + *
, and friends), leaving the bare text for measuring or for a + * filename. A single pass is not enough: replacing "ipt>" once + * removes the inner tag and leaves the halves joined into another, so the + * strip repeats until the text stops changing. + */ +export function stripHtmlTags(text: string): string { + let stripped = text + let previous = '' + do { + previous = stripped + stripped = stripped.replace(/<[^>]+>/g, '') + } while (stripped !== previous) + return stripped +} From eee83e7d844f799d537600d3f4a01b8a628d64af Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:28:22 +0000 Subject: [PATCH 2/2] docs(charts): clarify title handling and HTML tag stripping behavior --- frontend/src/components/AnalysisChart.tsx | 5 +++++ frontend/src/figure/charts.ts | 9 ++++++++- frontend/src/utils/text.ts | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/AnalysisChart.tsx b/frontend/src/components/AnalysisChart.tsx index 17e8a437..dcf1c775 100644 --- a/frontend/src/components/AnalysisChart.tsx +++ b/frontend/src/components/AnalysisChart.tsx @@ -5,6 +5,11 @@ import { stripHtmlTags } from '../utils/text' import { AddToReport } from './AddToReport' import { PlotlyChart } from './PlotlyChart' +/** + * Read a Plotly title from `layout.title` or its `text` property and strip tags + * for the default report caption. A missing or empty title returns null; a + * title containing only tags returns an empty string. Whitespace is preserved. + */ const figureTitle = (fig: unknown): string | null => { const t = (fig as { layout?: { title?: unknown } })?.layout?.title const text = typeof t === 'string' ? t : (t as { text?: string } | undefined)?.text diff --git a/frontend/src/figure/charts.ts b/frontend/src/figure/charts.ts index 6ee30a69..dcb58c29 100644 --- a/frontend/src/figure/charts.ts +++ b/frontend/src/figure/charts.ts @@ -102,7 +102,14 @@ function recolour(t: Record, colours: Record): return { ...t, marker: { ...marker, color: rgba(c, c[3]), ...(t.type === 'scatter' ? { line: outline } : {}) } } } -/** The chart sized, typeset and titled for its pane. */ +/** + * Return a JSON-cloned copy of the chart, sized, typeset and titled for its pane. + * A null pane title keeps the chart's title; an empty or whitespace-only title + * hides it. Title markup is retained for rendering but stripped for measuring + * the font size needed to fit the available width. + * Requires a browser document for text measurement. JSON serialisation, parsing + * and text measurement errors propagate to the caller. + */ export function paneFigure(raw: unknown, pane: FigurePane, doc: FigureDoc, opts: { showLegend: boolean widthPx: number diff --git a/frontend/src/utils/text.ts b/frontend/src/utils/text.ts index ffdecc9c..fc28108a 100644 --- a/frontend/src/utils/text.ts +++ b/frontend/src/utils/text.ts @@ -16,6 +16,8 @@ export function splitLines(text: string): string[] { * filename. A single pass is not enough: replacing "ipt>" once * removes the inner tag and leaves the halves joined into another, so the * strip repeats until the text stops changing. + * Entities are not decoded and removed tags add no spacing or line breaks. + * This is a text helper, not an HTML sanitiser. */ export function stripHtmlTags(text: string): string { let stripped = text