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.
- {statement.statement} -
+- {statement.inputSpecification} -
+- {statement.outputSpecification} -
+${paragraph.replaceAll('\n', '
')}
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
', + ); + }); +});