From e77bcea59d16c47d15b67860c2331c5cd67a672b Mon Sep 17 00:00:00 2001 From: enlorik <99548776+enlorik@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:36:48 +0200 Subject: [PATCH] Render Codeforces dollar-delimited math --- cfStatementParser.js | 70 +++++++++++++++++++++++++++-- cfStatementParser.test.js | 11 ++++- src/components/ProblemWorkspace.css | 27 +++++++++++ src/components/ProblemWorkspace.jsx | 33 ++++++++++---- src/utils/renderMath.js | 49 ++++++++++++++++++++ src/utils/renderMath.test.js | 24 ++++++++++ 6 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 src/utils/renderMath.js create mode 100644 src/utils/renderMath.test.js diff --git a/cfStatementParser.js b/cfStatementParser.js index dd7b838..273c8ed 100644 --- a/cfStatementParser.js +++ b/cfStatementParser.js @@ -1,6 +1,6 @@ /** * Server-side helper: parse a Codeforces problem page HTML string into - * structured, plain-text fields safe to return to the React frontend. + * structured fields safe to return to the React frontend. * * Returns null when the page does not contain a `.problem-statement` element * (e.g. the problem/contest does not exist or the URL was wrong). @@ -8,6 +8,59 @@ import * as cheerio from 'cheerio'; +const ALLOWED_TAGS = new Set([ + 'p', 'br', 'span', 'i', 'b', 'strong', 'em', 'sup', 'sub', 's', 'u', + 'ul', 'ol', 'li', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'code', 'pre', 'var', +]); + +const ALLOWED_CLASS_PREFIXES = [ + 'tex-', + 'MathJax', + 'mjx-', +]; + +function sanitizeClassName(className) { + return className + .split(/\s+/) + .filter(name => ALLOWED_CLASS_PREFIXES.some(prefix => name.startsWith(prefix))) + .join(' '); +} + +/** + * Return a conservative HTML fragment that preserves Codeforces' pre-rendered + * math markup (for example tex-span, sup, sub and italic variables) while + * stripping scripts, styles, event handlers, links and unrelated attributes. + * + * @param {import('cheerio').CheerioAPI} $ - cheerio root + * @param {import('cheerio').Cheerio} el - element to sanitize + * @returns {string} + */ +function safeHtml($, el) { + const clone = $(el).clone(); + clone.find('script, style, iframe, object, embed, link, meta').remove(); + + clone.find('*').each((_, node) => { + const tagName = node.tagName?.toLowerCase(); + const nodeEl = $(node); + + if (!ALLOWED_TAGS.has(tagName)) { + nodeEl.replaceWith(nodeEl.contents()); + return; + } + + const className = sanitizeClassName(nodeEl.attr('class') || ''); + for (const attr of Object.keys(node.attribs || {})) { + nodeEl.removeAttr(attr); + } + if (className) { + nodeEl.attr('class', className); + } + }); + + return clone.html()?.trim() || ''; +} + /** * Replace block-level children and
tags with newlines, then return the * trimmed text content of an element. This preserves paragraph breaks without @@ -17,6 +70,7 @@ import * as cheerio from 'cheerio'; * @param {import('cheerio').Cheerio} el - element to extract text from * @returns {string} */ + function blockText($, el) { const clone = $(el).clone(); clone.find('br').replaceWith('\n'); @@ -35,8 +89,9 @@ function blockText($, el) { * * @param {string} html - full HTML of the Codeforces problemset/problem page * @returns {{ title: string, timeLimit: string, memoryLimit: string, - * statement: string, inputSpecification: string, - * outputSpecification: string, + * statement: string, statementHtml: string, + * inputSpecification: string, inputSpecificationHtml: string, + * outputSpecification: string, outputSpecificationHtml: string, * samples: Array<{ input: string, output: string }> } | null} */ export function parseCFProblemStatement(html) { @@ -77,23 +132,29 @@ export function parseCFProblemStatement(html) { ]); const statementParts = []; + const statementHtmlParts = []; stmtEl.children().each((_, child) => { const classes = ($(child).attr('class') || '').split(/\s+/); if (classes.some(c => SKIP_CLASSES.has(c))) return; const text = blockText($, child); if (text) statementParts.push(text); + const html = safeHtml($, child); + if (html) statementHtmlParts.push(html); }); const statement = statementParts.join('\n\n'); + const statementHtml = statementHtmlParts.join('\n'); // ---- input specification -------------------------------------------------- const inputSpecClone = stmtEl.find('.input-specification').clone(); inputSpecClone.find('.section-title').remove(); const inputSpecification = blockText($, inputSpecClone); + const inputSpecificationHtml = safeHtml($, inputSpecClone); // ---- output specification ------------------------------------------------- const outputSpecClone = stmtEl.find('.output-specification').clone(); outputSpecClone.find('.section-title').remove(); const outputSpecification = blockText($, outputSpecClone); + const outputSpecificationHtml = safeHtml($, outputSpecClone); // ---- sample tests --------------------------------------------------------- const samples = []; @@ -108,8 +169,11 @@ export function parseCFProblemStatement(html) { timeLimit, memoryLimit, statement, + statementHtml, inputSpecification, + inputSpecificationHtml, outputSpecification, + outputSpecificationHtml, samples, }; } diff --git a/cfStatementParser.test.js b/cfStatementParser.test.js index 1866b37..f23ad8d 100644 --- a/cfStatementParser.test.js +++ b/cfStatementParser.test.js @@ -27,7 +27,7 @@ const FIXTURE_HTML = ` -

You are given two integers $a$ and $b$.

+

You are given two integers a and b2.

Print their sum.

@@ -196,12 +196,21 @@ describe('parseCFProblemStatement', () => { timeLimit: expect.any(String), memoryLimit: expect.any(String), statement: expect.any(String), + statementHtml: expect.any(String), inputSpecification: expect.any(String), + inputSpecificationHtml: expect.any(String), outputSpecification: expect.any(String), + outputSpecificationHtml: expect.any(String), samples: expect.any(Array), }); }); + it('preserves sanitized Codeforces math markup for rendering', () => { + const result = parseCFProblemStatement(FIXTURE_HTML); + expect(result.statementHtml).toContain('a'); + expect(result.statementHtml).toContain('2'); + }); + it('handles multi-paragraph statement and multi-line sample input', () => { const result = parseCFProblemStatement(FIXTURE_MULTI_PARA); expect(result.title).toBe('B. Multi Para'); diff --git a/src/components/ProblemWorkspace.css b/src/components/ProblemWorkspace.css index fd0c8f9..66593b7 100644 --- a/src/components/ProblemWorkspace.css +++ b/src/components/ProblemWorkspace.css @@ -341,3 +341,30 @@ white-space: pre; overflow-x: auto; } + +.problem-workspace-stmt-text p { + margin: 0 0 0.65rem; +} + +.problem-workspace-stmt-text p:last-child { + margin-bottom: 0; +} + +.problem-workspace-stmt-text .tex-span, +.problem-workspace-stmt-text sup, +.problem-workspace-stmt-text sub { + white-space: nowrap; +} + +.problem-workspace-math { + display: inline-block; + font-family: Georgia, 'Times New Roman', serif; + font-style: italic; + white-space: nowrap; +} + +.problem-workspace-math sup, +.problem-workspace-math sub { + font-size: 0.72em; + line-height: 0; +} diff --git a/src/components/ProblemWorkspace.jsx b/src/components/ProblemWorkspace.jsx index 7b456f7..61f7b1e 100644 --- a/src/components/ProblemWorkspace.jsx +++ b/src/components/ProblemWorkspace.jsx @@ -5,6 +5,7 @@ import { getDraftStorageKey, parseProblemWorkspaceQuery, } from '../utils/problemWorkspace'; +import { renderMathInHtml, textToHtml } from '../utils/renderMath'; import './ProblemWorkspace.css'; const DEFAULT_KOTLIN_STARTER = `fun main() { @@ -20,6 +21,17 @@ function loadDraft(storageKey) { } } +function StatementHtml({ html, fallback }) { + const renderedHtml = renderMathInHtml(html || textToHtml(fallback)); + + return ( +
+ ); +} + function ProblemWorkspace() { const { contestId = '', index = '' } = useParams(); const location = useLocation(); @@ -217,27 +229,30 @@ function ProblemWorkspace() {
{statement.statement && (
-

- {statement.statement} -

+
)} {statement.inputSpecification && (

Input

-

- {statement.inputSpecification} -

+
)} {statement.outputSpecification && (

Output

-

- {statement.outputSpecification} -

+
)} diff --git a/src/utils/renderMath.js b/src/utils/renderMath.js new file mode 100644 index 0000000..30678af --- /dev/null +++ b/src/utils/renderMath.js @@ -0,0 +1,49 @@ +export function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function normalizeLatex(latex) { + return latex + .replace(/\\\\/g, '\\') + .replace(/\\(?:ldots|dots|cdots)/g, '…') + .replace(/\\leq?/g, '≤') + .replace(/\\geq?/g, '≥') + .replace(/\\ne(?:q)?/g, '≠') + .replace(/\\cdot/g, '·') + .replace(/\\times/g, '×') + .replace(/\\oplus/g, '⊕') + .replace(/\\infty/g, '∞') + .replace(/\\sum/g, '∑') + .replace(/\\min/g, 'min') + .replace(/\\max/g, 'max') + .replace(/\\left|\\right/g, '') + .replace(/\\,/g, ' ') + .replace(/\\ /g, ' '); +} + +export function renderMathExpression(latex) { + let rendered = escapeHtml(normalizeLatex(latex).trim()); + + rendered = rendered.replace(/\^\{([^{}]+)\}/g, '$1'); + rendered = rendered.replace(/_\{([^{}]+)\}/g, '$1'); + rendered = rendered.replace(/\^([A-Za-z0-9+-])/g, '$1'); + rendered = rendered.replace(/_([A-Za-z0-9+-])/g, '$1'); + + return `${rendered}`; +} + +export function renderMathInHtml(html) { + return html.replace(/\${1,3}([\s\S]*?)\${1,3}/g, (_, latex) => renderMathExpression(latex)); +} + +export function textToHtml(text) { + return escapeHtml(text) + .split(/\n{2,}/) + .map(paragraph => `

${paragraph.replaceAll('\n', '
')}

`) + .join(''); +} diff --git a/src/utils/renderMath.test.js b/src/utils/renderMath.test.js new file mode 100644 index 0000000..82a67a0 --- /dev/null +++ b/src/utils/renderMath.test.js @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { renderMathInHtml, textToHtml } from './renderMath.js'; + +describe('renderMathInHtml', () => { + it('renders Codeforces triple-dollar math delimiters', () => { + const html = renderMathInHtml('

There are $$$n+1$$$ vertices.

'); + + expect(html).toContain(' { + const html = renderMathInHtml('

Range $$$0,1,\\dots,n^2$$$.

'); + + expect(html).toContain('0,1,…,n2'); + }); + + it('escapes plain-text fallbacks before adding paragraph markup', () => { + expect(textToHtml('\n\nnext')).toBe( + '

<script>x</script>

next

', + ); + }); +});