diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml
index 5f9faf8a..b7688d4f 100644
--- a/.github/workflows/Test.yml
+++ b/.github/workflows/Test.yml
@@ -15,4 +15,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
+ - run: npm install --ignore-scripts
+ - run: npx playwright install --with-deps chromium
- run: npm test
diff --git a/Update.json b/Update.json
index bf40a3c4..5a93f36b 100644
--- a/Update.json
+++ b/Update.json
@@ -3791,6 +3791,17 @@
}
],
"Notes": "No release notes were provided for this release."
+ },
+ "3.7.2": {
+ "UpdateDate": 1791030955802,
+ "Prerelease": true,
+ "UpdateContents": [
+ {
+ "PR": 1031,
+ "Description": "fix: preserve problem Markdown and add full-statement copying"
+ }
+ ],
+ "Notes": "No release notes were provided for this release."
}
}
}
\ No newline at end of file
diff --git a/XMOJ.user.js b/XMOJ.user.js
index 731c52ad..47fc2f93 100644
--- a/XMOJ.user.js
+++ b/XMOJ.user.js
@@ -1,6 +1,6 @@
// ==UserScript==
// @name XMOJ
-// @version 3.7.1
+// @version 3.7.2
// @description XMOJ增强脚本
// @author @XMOJ-Script-dev, @langningchen and the community
// @namespace https://github/langningchen
@@ -2653,13 +2653,7 @@ async function InitializeContestWebApp() {
}
// Sample boxes are cards, as on the legacy page.
for (const pre of root.querySelectorAll(".xmoj-problem-body .in-out pre")) pre.classList.add("card");
- if (UtilityEnabled("CopyMD")) {
- for (const section of root.querySelectorAll(".xmoj-problem-body .cnt-row")) {
- const heading = section.querySelector(".cnt-row-head");
- const body = section.querySelector(".cnt-row-body");
- if (heading && body && !body.querySelector(".sampledata")) AddCopy(heading, "copy-section", () => GetMDText(body).trim());
- }
- }
+ if (UtilityEnabled("CopyMD")) InitializeProblemMarkdownCopy(root, root, AddCopy);
// Built from this page's API response rather than a cached copy.
if (UtilityEnabled("ProblemSwitcher") && Array.isArray(routeData?.problems) && !document.querySelector(`[${owned}="problem-switcher"]`)) {
const list = routeData.problems.map(item => ({title: item.problemTitle || item.title || item.num, url: new URL(GetContestProblemURL(route.cid, item.num), location.origin).href}));
@@ -3887,25 +3881,8 @@ async function main() {
return Response.text();
}).then((Response) => {
let ParsedDocument = new DOMParser().parseFromString(Response, "text/html");
- let Temp = ParsedDocument.querySelectorAll(".cnt-row-body");
- if (UtilityEnabled("DebugMode")) console.log(Temp);
- for (let i = 0; i < Temp.length; i++) {
- if (Temp[i].children[0].className === "content lang_cn") {
- let CopyMDButton = document.createElement("button");
- CopyMDButton.className = "btn btn-sm btn-outline-secondary copy-btn";
- CopyMDButton.innerText = "复制";
- CopyMDButton.style.marginLeft = "10px";
- CopyMDButton.type = "button";
- document.querySelectorAll(".cnt-row-head.title")[i].appendChild(CopyMDButton);
- CopyMDButton.addEventListener("click", () => {
- GM_setClipboard(GetMDText(Temp[i].children[0]).trim().replaceAll("\n\t", "\n").replaceAll("\n\n", "\n"));
- CopyMDButton.innerText = "复制成功";
- setTimeout(() => {
- CopyMDButton.innerText = "复制";
- }, 1000);
- });
- }
- }
+ InitializeProblemMarkdownCopy(document.querySelector(".mt-3") || document,
+ ParsedDocument.querySelector(".jumbotron") || ParsedDocument);
});
}
@@ -7683,10 +7660,132 @@ function GetMDText(element) {
'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'
]);
const cellTags = new Set(['TD', 'TH']);
+ const mathItems = new Map();
+ const mathDocument = (typeof unsafeWindow === 'undefined' ? window : unsafeWindow).MathJax?.startup?.document;
+ // XMOJ also serves MathJax 3.0, before getMathItemsWithin was available.
+ const items = typeof mathDocument?.getMathItemsWithin === 'function' ?
+ mathDocument.getMathItemsWithin([element]) : mathDocument?.math || [];
+ for (const math of items) {
+ if (math.typesetRoot && element.contains(math.typesetRoot) && typeof math.math === 'string') {
+ mathItems.set(math.typesetRoot, math);
+ }
+ }
+
+ function AppendBreak(count) {
+ if (!result) return;
+ const trailing = result.match(/\n*$/)[0].length;
+ if (trailing < count) result += '\n'.repeat(count - trailing);
+ }
+
+ function AppendContent(content, cell) {
+ if (cell?.breakPending) {
+ AppendBreak(1);
+ cell.breakPending = false;
+ }
+ result += content;
+ }
+
+ function ReadMath(node) {
+ const math = mathItems.get(node);
+ if (math) return math;
+ if (node.nodeType === Node.ELEMENT_NODE && (node.classList.contains('katex') ||
+ node.classList.contains('katex-display') || node.nodeName.toUpperCase() === 'MATH')) {
+ const annotation = node.querySelector('annotation[encoding="application/x-tex"]');
+ if (annotation) return {math: annotation.textContent, display: node.classList.contains('katex-display') ||
+ node.parentElement?.classList.contains('katex-display') || node.getAttribute('display') === 'block'};
+ }
+ return null;
+ }
+
+ function AppendTable(table) {
+ const rows = [...table.rows];
+ const first = rows[0];
+ const hasHeader = first && first.cells.length > 0 && (first.parentElement.nodeName === 'THEAD' ||
+ [...first.cells].every(cell => cell.nodeName === 'TH'));
+ // Pipe tables require a header and cannot represent merged cells, nested tables or code blocks.
+ // Also retain HTML for links so their destinations are preserved.
+ // Replace rendered math with its source.
+ if (!hasHeader || table.querySelector('table, pre, a') || rows.some(row => [...row.cells].some(cell => cell.colSpan !== 1 || cell.rowSpan !== 1)) ||
+ table.tHead?.rows.length > 1) {
+ const copy = table.cloneNode(true);
+ function RestoreMath(original, clone) {
+ if (original.nodeName === 'PRE') return;
+ const math = ReadMath(original);
+ if (math) {
+ clone.replaceWith(document.createTextNode((math.display ? '$$' : '$') + math.math + (math.display ? '$$' : '$')));
+ return;
+ }
+ for (const attribute of ['src', 'href']) {
+ const value = original.getAttribute?.(attribute);
+ if (value) {
+ try {
+ clone.setAttribute(attribute, new URL(value, location.href).href);
+ } catch (e) {
+ // Keep the source value when it is not a valid URL.
+ }
+ }
+ }
+ const children = [...clone.childNodes];
+ [...original.childNodes].forEach((child, index) => RestoreMath(child, children[index]));
+ }
+ RestoreMath(table, copy);
+ AppendBreak(2);
+ // Encode literal line endings so blank lines inside HTML do not end
+ // the Markdown HTML block. PRE contents retain them after rendering.
+ AppendContent(copy.outerHTML.replace(/\r\n|\r|\n/g, '
'));
+ AppendBreak(2);
+ return;
+ }
+ if (table.caption) {
+ AppendBreak(2);
+ Traverse(table.caption);
+ AppendBreak(2);
+ }
+ if (!rows.length || !rows.some(row => row.cells.length)) return;
+ const values = rows.map(row => [...row.cells].map(cell => {
+ const previous = result;
+ result = '';
+ Traverse(cell);
+ const value = result.replace(/\t$/, '').trim().replace(/(\\*)\|/g, (_, slashes) => slashes + slashes + '\\|')
+ .replace(/ *\r?\n/g, '
');
+ result = previous;
+ return value;
+ }));
+ const width = Math.max(...values.map(row => row.length));
+ const header = values.shift();
+ const Line = row => '| ' + Array.from({length: width}, (_, index) => row[index] || '').join(' | ') + ' |';
+ const separators = Array.from({length: width}, (_, index) => {
+ const cell = first.cells[index];
+ const alignment = cell?.style.textAlign || cell?.getAttribute('align');
+ return alignment === 'center' ? ':---:' : alignment === 'right' ? '---:' : alignment === 'left' ? ':---' : '---';
+ });
+ AppendBreak(2);
+ AppendContent([Line(header), Line(separators), ...values.map(Line)].join('\n'));
+ AppendBreak(2);
+ }
- function traverse(node) {
+ function AppendMath(tex, display, inCell) {
+ // Keep a table cell on its row, including its trailing tab separator.
+ if (display && !inCell) AppendBreak(2);
+ AppendContent(display ? (inCell ? '$$' + tex + '$$' : '$$\n' + tex + '\n$$') : '$' + tex + '$', inCell);
+ if (display && !inCell) AppendBreak(2);
+ }
+
+ function Traverse(node, inPre = false, inCell = false) {
if (node.nodeType === Node.TEXT_NODE) {
- result += node.textContent;
+ // Serialized HTML often has indentation between blocks and cells.
+ // Keep inline spaces and all preformatted text.
+ const boundary = node => blockTags.has(node?.nodeName) || cellTags.has(node?.nodeName);
+ const before = boundary(node.previousSibling) || (boundary(node.parentElement) && !node.previousSibling);
+ const after = boundary(node.nextSibling) || (boundary(node.parentElement) && !node.nextSibling);
+ let text = node.textContent;
+ if (!inPre && /^[ \t\r\n]*$/.test(text) && (before || after)) return;
+ if (!inPre) {
+ text = text.replace(/[ \t\r\n]+/g, ' ');
+ if (before) text = text.replace(/^ +/, '');
+ if (after) text = text.replace(/ +$/, '');
+ }
+ AppendContent(text, inCell);
return;
}
@@ -7696,9 +7795,44 @@ function GetMDText(element) {
const tag = node.nodeName.toUpperCase();
+ if (tag === 'PRE' && !inPre) {
+ // Read literal code (including highlighted spans) without converting math
+ // or collapsing its indentation. BR is also used by older editors.
+ function ReadCode(child) {
+ if (child.nodeType === Node.TEXT_NODE) return child.textContent;
+ if (child.nodeName === 'BR') return '\n';
+ return [...child.childNodes].map(ReadCode).join('');
+ }
+ const code = ReadCode(node);
+ if (inCell) {
+ inCell.breakPending = true;
+ AppendContent(code, inCell);
+ inCell.breakPending = true;
+ } else {
+ const classes = [node.querySelector('code')?.className, node.className].filter(Boolean).join(' ');
+ const language = classes.match(/(?:^|\s)(?:language|lang)-([\w+-]+)(?=\s|$)/)?.[1] || 'plain';
+ AppendBreak(2);
+ AppendContent(GetMarkdownCodeBlock(code, language));
+ AppendBreak(2);
+ }
+ return;
+ }
+
+ if (tag === 'TABLE') {
+ AppendTable(node);
+ return;
+ }
+
+ // Copy the source once, rather than the visual and accessibility trees.
+ const math = ReadMath(node);
+ if (math) {
+ AppendMath(math.math, math.display, inCell);
+ return;
+ }
+
// Preserve line breaks for
if (tag === 'BR') {
- result += '\n';
+ AppendContent(inPre ? '\n' : ' \n', inCell);
return;
}
@@ -7712,40 +7846,157 @@ function GetMDText(element) {
} catch (e) {
// Fallback to the raw src if URL construction fails
}
- result += ``;
+ AppendContent(``, inCell);
}
return;
}
const isBlock = blockTags.has(tag);
const isCell = cellTags.has(tag);
-
- if (isBlock && !result.endsWith('\n')) {
- result += '\n';
+ const breaks = tag === 'TR' ? 1 : 2;
+
+ // Each cell has its own buffer. Defer block breaks until its next content
+ // so nested paragraphs separate without leading/trailing row breaks.
+ if (isCell) {
+ const previous = result;
+ result = '';
+ const cell = {breakPending: false};
+ for (const child of node.childNodes) Traverse(child, inPre, cell);
+ result = previous + result + '\t';
+ return;
}
- // Keep table cells visually separated when copied as plain text.
- if (isCell && result.length > 0 && !result.endsWith('\n') && !result.endsWith('\t') && !result.endsWith(' ')) {
- result += '\t';
+ if (isBlock && !inPre) {
+ if (inCell) inCell.breakPending = true;
+ else AppendBreak(breaks);
}
for (let child of node.childNodes) {
- traverse(child);
+ Traverse(child, inPre || tag === 'PRE', inCell);
}
- if (isCell && !result.endsWith('\n') && !result.endsWith('\t')) {
- result += '\t';
+ if (isBlock && !inPre) {
+ if (inCell) inCell.breakPending = true;
+ else AppendBreak(breaks);
}
+ }
+
+ Traverse(element);
+ return result;
+}
+
+function GetMarkdownCodeBlock(code, language = "plain") {
+ // A backtick run in the source must not terminate the copied code block.
+ const runs = [...code.matchAll(/`+/g)].map(match => match[0].length + 1);
+ const fence = "`".repeat(Math.max(3, ...runs));
+ return fence + language + "\n" + code + (code.endsWith("\n") ? "" : "\n") + fence;
+}
+
+// Use the displayed section labels, excluding copy controls and alternate languages.
+function GetProblemCopyHeading(element, language = "zh") {
+ if (!element) return "";
+ const copy = element.cloneNode(true);
+ const otherLanguage = language === "en" ? ".lang_cn" : ".lang_en";
+ for (const node of copy.querySelectorAll("button, .copy-btn, [data-xmoj-script], " + otherLanguage)) node.remove();
+ return copy.textContent.replace(/\s+/g, " ").trim();
+}
- if (isBlock && !result.endsWith('\n')) {
- result += '\n';
+function GetProblemCopyLanguage(root) {
+ const english = root.querySelector(".content.lang_en");
+ return english?.isConnected && !english.closest("[hidden], .hidden, .d-none") &&
+ getComputedStyle(english).display !== "none" ? "en" : "zh";
+}
+
+function GetProblemSectionMarkdown(section, language = "zh") {
+ const heading = GetProblemCopyHeading(section.querySelector(".cnt-row-head"), language);
+ const body = section.querySelector(".cnt-row-body");
+ if (!heading || !body) return "";
+ let content;
+ if (body.querySelector(".sampledata, .data-sample, .in-out-item")) {
+ const parts = [];
+ const samples = [...body.querySelectorAll(".data-sample")];
+ const groups = samples.length ? samples : [...body.querySelectorAll(".in-out")];
+ function Visit(node) {
+ if (node.nodeType !== Node.ELEMENT_NODE ||
+ node.classList.contains(language === "en" ? "lang_cn" : "lang_en")) return;
+ if (node.classList.contains("in-out-item")) {
+ const sample = node.querySelector(".sampledata");
+ if (!sample || sample.textContent === "") return;
+ let label = GetProblemCopyHeading(node.querySelector(".title"), language);
+ const group = node.closest(samples.length ? ".data-sample" : ".in-out");
+ if (!/#\s*\d+/.test(label)) label += " #" + (Math.max(0, groups.indexOf(group)) + 1);
+ const code = sample.textContent;
+ parts.push("### " + label + "\n\n" + GetMarkdownCodeBlock(code));
+ return;
+ }
+ if (node.classList.contains("title")) {
+ const label = GetProblemCopyHeading(node, language);
+ if (label) parts.push("### " + label);
+ return;
+ }
+ // Legacy pages wrap the complete sample section in .content.
+ if (node.classList.contains("content") && !node.querySelector(".sampledata, .data-sample, .in-out-item")) {
+ const text = GetMDText(node).trim();
+ if (text) parts.push(text);
+ return;
+ }
+ for (const child of node.children) Visit(child);
}
+ for (const child of body.children) Visit(child);
+ content = parts.join("\n\n");
+ } else {
+ const translated = body.querySelector(language === "en" ? ".content.lang_en" : ".content.lang_cn");
+ const fallback = body.querySelector(".content.lang_cn") || body.querySelector(".content");
+ content = GetMDText(translated || fallback || body).trim();
}
+ return content ? "## " + heading + "\n\n" + content : "";
+}
- traverse(element);
- return result;
+function GetProblemStatementMarkdown(root, language = "zh") {
+ const title = root.querySelector(".xmoj-problem-head h2") ||
+ root.querySelector(language === "en" ? "h2.lang_en" : "h2.lang_cn") || root.querySelector("h2");
+ const name = GetProblemCopyHeading(title, language)
+ .replace(/^(?:(?:问题|Problem)\s+[^::]+|\d+)\s*[::]\s*/i, "");
+ const body = root.querySelector(".xmoj-problem-body") || root;
+ const sections = [...body.querySelectorAll(".cnt-row")]
+ .map(section => GetProblemSectionMarkdown(section, language)).filter(Boolean);
+ return (name ? ["# " + name, ...sections] : sections).join("\n\n");
+}
+
+function InitializeProblemMarkdownCopy(root, sourceRoot = root, addCopy = null) {
+ if (!addCopy) addCopy = (parent, name, readText) => {
+ if (parent.querySelector('[data-xmoj-script="' + name + '"]')) return;
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "btn btn-sm btn-outline-secondary copy-btn";
+ button.style.marginLeft = "10px";
+ button.setAttribute("data-xmoj-script", name);
+ button.textContent = "复制";
+ button.addEventListener("click", () => {
+ GM_setClipboard(readText());
+ button.textContent = "复制成功";
+ setTimeout(() => { button.textContent = "复制"; }, 1000);
+ });
+ parent.appendChild(button);
+ };
+ const titles = [...root.querySelectorAll(".xmoj-problem-head h2, h2.lang_cn, h2.lang_en")];
+ if (!titles.length && root.querySelector("h2")) titles.push(root.querySelector("h2"));
+ for (const title of titles) {
+ addCopy(title, "copy-problem", () => GetProblemStatementMarkdown(sourceRoot, GetProblemCopyLanguage(root)));
+ }
+ const body = root.querySelector(".xmoj-problem-body") || root;
+ const sourceBody = sourceRoot.querySelector(".xmoj-problem-body") || sourceRoot;
+ const sourceSections = [...sourceBody.querySelectorAll(".cnt-row")];
+ for (const [index, section] of [...body.querySelectorAll(".cnt-row")].entries()) {
+ const heading = section.querySelector(".cnt-row-head");
+ if (heading && section.querySelector(".cnt-row-body")) {
+ addCopy(heading, "copy-section", () =>
+ GetProblemSectionMarkdown(sourceSections[index] || section, GetProblemCopyLanguage(root)));
+ }
+ }
}
+
function InitializeImageEnlarger() {
// Image Enlargement Feature
if (UtilityEnabled("ImageEnlarger")) {
diff --git a/package.json b/package.json
index 36d1d15b..a4596849 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,13 @@
{
"name": "xmoj-script",
- "version": "3.7.1",
+ "version": "3.7.2",
"description": "an improvement script for xmoj.tech",
"main": "AddonScript.js",
"scripts": {
- "test": "node --test tests/contest-web.test.cjs"
+ "test": "node --test tests/contest-web.test.cjs tests/markdown-copy.test.cjs"
+ },
+ "devDependencies": {
+ "playwright": "1.59.1"
},
"repository": {
"type": "git",
diff --git a/tests/markdown-copy.html b/tests/markdown-copy.html
new file mode 100644
index 00000000..10727025
--- /dev/null
+++ b/tests/markdown-copy.html
@@ -0,0 +1,289 @@
+
+
+
Running…+ diff --git a/tests/markdown-copy.test.cjs b/tests/markdown-copy.test.cjs new file mode 100644 index 00000000..9eb87265 --- /dev/null +++ b/tests/markdown-copy.test.cjs @@ -0,0 +1,58 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const {chromium} = require('playwright'); + +const source = fs.readFileSync(path.join(__dirname, '../XMOJ.user.js'), 'utf8'); +const fixture = fs.readFileSync(path.join(__dirname, 'markdown-copy.html'), 'utf8'); + +// Uses the same browser fixture as the manual checks, with no external requests. +async function RunFixture(browser, script) { + const page = await browser.newPage(); + try { + await page.route('https://markdown-copy.test/**', route => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === '/tests/markdown-copy.html') { + return route.fulfill({contentType: 'text/html', body: fixture}); + } + if (pathname === '/XMOJ.user.js') { + return route.fulfill({contentType: 'text/javascript', body: script}); + } + return route.fulfill({status: 404, body: ''}); + }); + await page.goto('https://markdown-copy.test/tests/markdown-copy.html'); + await page.waitForFunction(() => window.markdownCopyResults); + return await page.evaluate(() => window.markdownCopyResults); + } finally { + await page.close(); + } +} + +test('Markdown copy browser regressions', {timeout: 60000}, async t => { + // XMOJ_CHROMIUM also lets developers reuse the browser from the live harness. + const browser = await chromium.launch({executablePath: process.env.XMOJ_CHROMIUM || undefined}); + try { + await t.test('preserves math and formatting without changing the DOM', async () => { + const result = await RunFixture(browser, source); + assert.equal(result.error, undefined, result.error); + assert.ok(result.passed >= 16); + assert.equal(result.passed, result.tests.length); + t.diagnostic(result.tests.join(', ')); + }); + for (const anchor of ['function GetMDText(', 'function InitializeImageEnlarger(']) { + await t.test('reports a missing extraction anchor: ' + anchor, async () => { + const result = await RunFixture(browser, source.replace(anchor, 'function MissingAnchor(')); + assert.match(result.error, /Cannot locate a unique GetMDText function/); + assert.equal(result.passed, 0); + }); + } + await t.test('reports duplicate extraction anchors', async () => { + const result = await RunFixture(browser, source + '\nfunction GetMDText() {}'); + assert.match(result.error, /Cannot locate a unique GetMDText function/); + assert.equal(result.passed, 0); + }); + } finally { + await browser.close(); + } +});