diff --git a/frontend/src/components/AnalysisChart.tsx b/frontend/src/components/AnalysisChart.tsx index 3e40f157..dcf1c775 100644 --- a/frontend/src/components/AnalysisChart.tsx +++ b/frontend/src/components/AnalysisChart.tsx @@ -1,13 +1,19 @@ // © 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' +/** + * 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 - 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..dcb58c29 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' @@ -101,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 @@ -119,7 +127,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..fc28108a 100644 --- a/frontend/src/utils/text.ts +++ b/frontend/src/utils/text.ts @@ -9,3 +9,22 @@ 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. + * 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 + let previous = '' + do { + previous = stripped + stripped = stripped.replace(/<[^>]+>/g, '') + } while (stripped !== previous) + return stripped +}