Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion frontend/src/components/AnalysisChart.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/figure/charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -101,7 +102,14 @@ function recolour(t: Record<string, unknown>, colours: Record<string, string>):
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
Expand All @@ -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<string, unknown>) }
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/utils/text.test.ts
Original file line number Diff line number Diff line change
@@ -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('CO<sub>2</sub> by <b>run</b>')).toBe('CO2 by run')
})

test('a tag hiding inside another does not survive', () => {
expect(stripHtmlTags('<scr<script>ipt>alert(1)</script>')).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'])
})
})
19 changes: 19 additions & 0 deletions frontend/src/utils/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<b>,
* <br>, <sub> and friends), leaving the bare text for measuring or for a
* filename. A single pass is not enough: replacing "<scr<script>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, '')

Check warning on line 27 in frontend/src/utils/text.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_MetaManifold-WebUI&issues=AaFJWOAQ486RX33iG3DA&open=AaFJWOAQ486RX33iG3DA&pullRequest=179
} while (stripped !== previous)
return stripped
}
Loading