Something went wrong. Try again.
[PRE-ALPHA] an open source web-based 2D/3D graphing calculator cartesium.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822import assert from 'node:assert/strict';import { createServer } from 'node:http';import { mkdtemp, rm } from 'node:fs/promises';import { tmpdir } from 'node:os';import { join, resolve } from 'node:path';import { spawn } from 'node:child_process';import { gzipSync } from 'node:zlib';
import { delay, evaluate, launchChromium } from './raw-cdp-browser.mjs';
const host = '127.0.0.1';const appRoot = resolve(import.meta.dirname, '..');const viteEntrypoint = resolve(appRoot, 'node_modules/vite/bin/vite.js');const browserProfile = await mkdtemp(join(tmpdir(), 'cartesium-graph-text-'));let vite;let chromium;let browser;
try { const port = await findFreePort(); const origin = `http://${host}:${port}`; vite = spawn( process.execPath, [viteEntrypoint, 'dev', '--host', host, '--port', String(port), '--strictPort'], { cwd: appRoot, stdio: ['ignore', 'pipe', 'pipe'] } ); const viteOutput = []; collectOutput(vite, viteOutput); await waitForServer(`${origin}/calc/2d`, vite, viteOutput); const launched = await launchChromium(browserProfile); chromium = launched.process; browser = launched.browser; const browserLogs = []; browser.on('Runtime.consoleAPICalled', (event) => { browserLogs.push(event.args.map((argument) => argument.value ?? argument.description)); }); browser.on('Runtime.exceptionThrown', (event) => { browserLogs.push(['exception', event.exceptionDetails?.text, event.exceptionDetails?.url, event.exceptionDetails?.lineNumber, event.exceptionDetails?.columnNumber, event.exceptionDetails?.exception?.description]); }); browser.on('Log.entryAdded', (event) => browserLogs.push(['log', event.entry?.source, event.entry?.text, event.entry?.url, event.entry?.lineNumber])); const { targetId } = await browser.command('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await browser.command('Target.attachToTarget', { targetId, flatten: true });
const command = (method, params = {}) => browser.command(method, params, sessionId); await command('Page.enable'); await command('Runtime.enable'); await command('Log.enable'); await command('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }); await command('Page.navigate', { url: `${origin}/calc/2d` }); await waitForEvaluation( command, `(() => ({ ready: Boolean(document.querySelector('.plot-canvas') && [...document.querySelectorAll('button')].some(button => button.textContent.trim() === 'Export PNG')), body: document.body.innerText.slice(0, 500), pageError: document.querySelector('[role="alert"]')?.textContent }))()`, '2D graph toolbar', 30_000, (state) => state.ready ); await evaluate( command, `(() => { window.__graphTextPngs = []; window.__graphTextPngBlobs = []; HTMLAnchorElement.prototype.click = function () { if (this.download.endsWith('.png')) { window.__graphTextPngs.push(this.href); window.__graphTextPngBlobs.push(fetch(this.href).then(response => response.blob())); } }; })()` ); await clickExport(command); await waitForEvaluation(command, 'window.__graphTextPngs.length === 1', 'baseline PNG export'); await evaluate( command, `(async () => { const bitmap = await createImageBitmap(await window.__graphTextPngBlobs[0]); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; canvas.getContext('2d').drawImage(bitmap, 0, 0); window.__graphTextBaseline = canvas; return { width: canvas.width, height: canvas.height }; })()` ); await evaluate(command, `document.querySelector('.add-text').click()`); await waitForEvaluation(command, `Boolean(document.querySelector('.text-editor textarea'))`, 'text editor'); await evaluate( command, `(() => { const editor = document.querySelector('.text-editor textarea'); const source = 'Note **bold** and $x^2$   [bad](javascript:alert(1))'; Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(editor, source); editor.dispatchEvent(new Event('input', { bubbles: true })); })()` );
const sourceRowPreview = await evaluate( command, `document.querySelector('.text-row-preview')?.textContent ?? ''` );
assert(sourceRowPreview.includes('Note **bold**'), 'text rows should preview their Markdown and LaTeX source'); await evaluate(command, `document.querySelector('.text-row-container .toggle input').click()`); await waitForEvaluation(command, `!document.querySelector('.text-item')`, 'disabled text hidden from graph'); assert( (await evaluate(command, `document.querySelector('.text-row-container .toggle input').checked`)) === false, 'text visibility toggle should reflect the disabled state' ); await evaluate(command, `document.querySelector('.text-row-container .toggle input').click()`); await waitForEvaluation(command, `Boolean(document.querySelector('.text-item'))`, 'enabled text restored to graph'); assert( (await evaluate(command, `document.querySelector('.text-row').getAttribute('aria-expanded')`)) === 'true', 'new text should open for editing' ); await evaluate(command, `document.querySelector('.text-row').click()`); await waitForEvaluation( command, `!document.querySelector('.text-editor') && !document.querySelector('.text-item .resize-handle') && document.querySelector('.text-row').getAttribute('aria-expanded') === 'false'`, 'clicking an open text row should collapse its controls and graph handle' ); await evaluate(command, `document.querySelector('.text-row').click()`); await waitForEvaluation( command, `Boolean(document.querySelector('.text-item.selected') && document.querySelector('.text-item .resize-handle')) && !document.querySelector('.text-controls')`, 'sidebar selection enabling the graph text box without a toolbar' );
const centerBeforeEdit = await evaluate( command, `(() => { const input = document.querySelector('[data-text-field="position-x"]'); const rect = document.querySelector('.text-item').getBoundingClientRect(); return { x: input.value, left: rect.left }; })()` );
await setTextNumber(command, 'position-x', '2.5');
const centerAfterEdit = await evaluate( command, `(() => { const input = document.querySelector('[data-text-field="position-x"]'); const rect = document.querySelector('.text-item').getBoundingClientRect(); return { x: input.value, left: rect.left }; })()` );
assert(centerAfterEdit.x === '2.5', 'center x should accept a finite graph coordinate'); assert(centerAfterEdit.left > centerBeforeEdit.left, 'changing center x should move the graph text box'); await setTextNumber(command, 'position-x', '1e13'); assert( (await evaluate(command, `document.querySelector('[data-text-field="position-x"]').value`)) === '2.5', 'anchor x outside the supported range should be rejected' );
const beforeAnchorChange = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; })()` );
await evaluate(command, `document.querySelector('[data-text-anchor="top-right"]').click()`);
const afterAnchorChange = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, selected: document.querySelector('[data-text-anchor="top-right"]').getAttribute('aria-pressed') }; })()` );
assert(Math.abs(afterAnchorChange.left - beforeAnchorChange.left) < 1, 'changing anchor should keep the live box left edge fixed'); assert(Math.abs(afterAnchorChange.top - beforeAnchorChange.top) < 1, 'changing anchor should keep the live box top edge fixed'); assert(afterAnchorChange.width === beforeAnchorChange.width && afterAnchorChange.height === beforeAnchorChange.height, 'changing anchor should keep the live box size'); assert(afterAnchorChange.selected === 'true', 'sidebar should select the top-right anchor'); await setTextNumber(command, 'width', '20'); await setTextNumber(command, 'height', '3000'); await setTextNumber(command, 'font-size', '300');
const limitedDimensions = await evaluate( command, `[...document.querySelectorAll('[data-text-field]')].reduce((result, input) => (result[input.dataset.textField] = input.value, result), {})` );
assert(limitedDimensions.width === '80', 'text width should clamp to the snapshot minimum'); assert(limitedDimensions.height === '2000', 'text height should clamp to the snapshot maximum'); assert(limitedDimensions['font-size'] === '96', 'text font size should clamp to the snapshot maximum'); await setTextNumber(command, 'position-x', centerBeforeEdit.x); await setTextNumber(command, 'width', '240'); await setTextNumber(command, 'height', '96'); await setTextNumber(command, 'font-size', '16'); await evaluate(command, `document.querySelector('.add-text').click()`); await waitForEvaluation( command, `document.querySelectorAll('.text-row').length === 2 && document.querySelectorAll('.text-item').length === 2`, 'second text element' ); await setTextNumber(command, 'position-x', '-2'); await evaluate(command, `document.querySelectorAll('.text-row')[0].click()`);
const secondTextRect = await evaluate( command, `(() => { const rect = document.querySelectorAll('.text-item')[1].getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; })()` );
await clickAt(command, secondTextRect); assert( (await evaluate(command, `document.querySelectorAll('.text-row')[1].getAttribute('aria-expanded')`)) === 'true', 'clicking a text box should select its sidebar row' ); await evaluate(command, `document.querySelector('.delete-text').click()`); await waitForEvaluation(command, `document.querySelectorAll('.text-row').length === 1`, 'delete text element'); await evaluate(command, `document.querySelector('.text-row').click()`); await setTextSource(command, 'Note **bold** and $x^2$   [bad](javascript:alert(1))');
try { await waitForEvaluation( command, `(() => { const item = document.querySelector('.text-item'); const math = item?.querySelector('mjx-container'); const image = item?.querySelector('img'); return { ready: Boolean(math && image?.src.startsWith('data:image/png') && item?.querySelector('.graph-text-image-error')), itemText: item?.textContent, itemHtml: item?.innerHTML.slice(0, 500), imageSrc: image?.src.slice(0, 80), editorValue: document.querySelector('.text-editor textarea')?.value }; })()`, 'MathJax and inline-image rendering', 30_000, (state) => state.ready ); } catch (error) { console.error(JSON.stringify(browserLogs)); throw error; }
const safety = await evaluate( command, `({ rawImageTags: document.querySelectorAll('.text-item img[src^="javascript:"]').length, unsafeLinks: document.querySelectorAll('.text-item a[href^="javascript:"]').length, remoteImages: [...document.querySelectorAll('.text-item img')].filter(image => !image.src.startsWith('data:')).length })` );
assert(safety.rawImageTags === 0, 'unsafe image URLs must not render'); assert(safety.unsafeLinks === 0, 'unsafe Markdown links must not render as active links'); assert(safety.remoteImages === 0, 'images must be inlined before live rendering and export');
const beforeMove = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; })()` );
await dragPointer( command, { x: beforeMove.left + 45, y: beforeMove.top + 25 }, { x: beforeMove.left + 105, y: beforeMove.top + 65 } );
const afterMove = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; })()` );
assert(Math.abs(afterMove.left - beforeMove.left - 60) < 3, 'selected text should drag horizontally'); assert(Math.abs(afterMove.top - beforeMove.top - 40) < 3, 'selected text should drag vertically'); assert(afterMove.width === beforeMove.width && afterMove.height === beforeMove.height, 'dragging should preserve the display box');
const beforeResize = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); const handle = document.querySelector('[data-text-resize-handle]').getBoundingClientRect(); return { rect: { width: rect.width, height: rect.height }, handle: { x: handle.right - 1, y: handle.bottom - 1 } }; })()` );
await dragPointer( command, beforeResize.handle, { x: beforeResize.handle.x + 60, y: beforeResize.handle.y + 40 } );
const afterResize = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { width: rect.width, height: rect.height }; })()` );
assert(afterResize.width > beforeResize.rect.width + 50, 'resize handle should increase text width'); assert(afterResize.height > beforeResize.rect.height + 30, 'resize handle should increase text height'); const fixtureResults = [];
for (const [label, source] of [ ['plain text', 'Plain text export fixture'], ['math only', 'Math export fixture $x^2$'], ['display fraction and integral', '$$\\frac{1}{2 \\pi} \\int e^{-x^2} dx$$'], ['image only', 'Image export fixture '] ]) { await setTextSource(command, source); await delay(1_000); const expectedCount = 2 + fixtureResults.length; await clickExport(command); let fixture;
try { fixture = await waitForEvaluation( command, `({ count: window.__graphTextPngs.length, tainted: document.body.innerText.includes('Tainted canvases'), status: document.body.innerText.match(/Preparing PNG…|Downloaded [^\\n]+|Failed to execute[^\\n]+|MathJax SVG[^\\n]+|Could not load[^\\n]+/)?.[0] ?? '' })`, `${label} PNG export`, 10_000, (state) => (state.count === expectedCount || state.tainted || state.status.startsWith('MathJax') || state.status.startsWith('Could not')) && !state.status.startsWith('Preparing') ); } catch (error) { console.error(JSON.stringify(browserLogs)); throw error; }
fixtureResults.push({ label, ...fixture, expectedCount }); }
console.log(JSON.stringify({ fixtureResults })); assert( fixtureResults.every(({ count, expectedCount, tainted }) => count === expectedCount && !tainted), `text export fixtures must remain origin-clean: ${JSON.stringify(fixtureResults)}` );
const runtimeBuild = await evaluate(command, `(() => { const frame = document.querySelector('iframe[title="MathJax SVG export runtime"]'); const script = frame?.contentDocument?.querySelector('script[src*="mathjax-svg-runtime.js"]'); return { requested: script ? new URL(script.src).searchParams.get('v') : null, current: document.querySelector('[data-build-commit]')?.getAttribute('data-build-commit') }; })()`);
assert(runtimeBuild.requested === runtimeBuild.current, `MathJax export runtime must match the app build: ${JSON.stringify(runtimeBuild)}`); await setTextSource( command, 'Note **bold** and $x^2$   [bad](javascript:alert(1))' ); await delay(1_000);
await clickExport(command);
try { await waitForEvaluation( command, `({ count: window.__graphTextPngs.length, alerts: [...document.querySelectorAll('[role="alert"]')].map(node => node.textContent), buttons: [...document.querySelectorAll('button')].map(button => [button.textContent.trim(), button.disabled]), body: document.body.innerText.slice(-700) })`, 'annotated PNG export', 30_000, (state) => state.count === 6 ); } catch (error) { console.error(JSON.stringify(browserLogs)); throw error; }
const exportResult = await evaluate( command, `(async () => { const bitmap = await createImageBitmap(await window.__graphTextPngBlobs[5]); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const current = context.getImageData(0, 0, canvas.width, canvas.height).data; const baseline = window.__graphTextBaseline.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; const surface = document.querySelector('.graph-area .surface'); const surfaceRect = surface.getBoundingClientRect(); const textRect = document.querySelector('.text-item').getBoundingClientRect(); const scaleX = canvas.width / surfaceRect.width; const scaleY = canvas.height / surfaceRect.height; const left = Math.max(0, Math.floor((textRect.left - surfaceRect.left) * scaleX)); const top = Math.max(0, Math.floor((textRect.top - surfaceRect.top) * scaleY)); const right = Math.min(canvas.width, Math.ceil(left + textRect.width * scaleX)); const bottom = Math.min(canvas.height, Math.ceil(top + textRect.height * scaleY)); let changedPixels = 0; for (let y = top; y < bottom; y += 1) { for (let x = left; x < right; x += 1) { const index = (y * canvas.width + x) * 4; if (Math.abs(current[index] - baseline[index]) + Math.abs(current[index + 1] - baseline[index + 1]) + Math.abs(current[index + 2] - baseline[index + 2]) > 45) changedPixels += 1; } } return { width: canvas.width, height: canvas.height, changedPixels, nonCenterAnchor: document.querySelector('[data-text-anchor="top-right"]')?.getAttribute('aria-pressed') === 'true' }; })()` );
assert(exportResult.changedPixels > 40, `expected exported text pixels in the annotation bounds, got ${JSON.stringify(exportResult)}`); assert(exportResult.nonCenterAnchor, 'PNG export should include text using the selected non-center anchor');
const beforeZoom = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); const surface = document.querySelector('.graph-area .surface').getBoundingClientRect(); return { width: rect.width, height: rect.height, fontSize: getComputedStyle(document.querySelector('.text-item')).fontSize, x: surface.left + 20, y: surface.top + 20 }; })()` );
await command('Input.dispatchMouseEvent', { type: 'mouseWheel', x: beforeZoom.x, y: beforeZoom.y, deltaY: -500, deltaX: 0 }); await delay(500);
const afterZoom = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { width: rect.width, height: rect.height, fontSize: getComputedStyle(document.querySelector('.text-item')).fontSize }; })()` );
assert(afterZoom.width === beforeZoom.width, 'zoom should preserve annotation width in CSS pixels'); assert(afterZoom.height === beforeZoom.height, 'zoom should preserve annotation height in CSS pixels'); assert(afterZoom.fontSize === beforeZoom.fontSize, 'zoom should preserve annotation font size'); await evaluate( command, `document.querySelector('article[data-expression-id] input[type="checkbox"]').click()` ); assert( (await evaluate(command, `document.querySelector('.text-row').getAttribute('aria-expanded')`)) === 'false', 'selecting an expression should collapse text controls' ); await evaluate( command, `(() => { const theme = document.querySelector('.theme-picker select'); theme.value = 'dark'; theme.dispatchEvent(new Event('change', { bubbles: true })); })()` ); await waitForEvaluation(command, `document.querySelector('main').classList.contains('dark')`, 'dark theme'); await evaluate(command, `document.querySelector('.text-row').click()`);
const darkTextStyles = await evaluate( command, `(() => ({ section: getComputedStyle(document.querySelector('.text-section')).backgroundColor, row: getComputedStyle(document.querySelector('.text-row')).backgroundColor, rowText: getComputedStyle(document.querySelector('.text-row')).color, input: getComputedStyle(document.querySelector('.text-editor input')).backgroundColor }))()` );
assert(darkTextStyles.section === 'rgb(17, 24, 39)', `text section should use the dark panel color: ${JSON.stringify(darkTextStyles)}`); assert(darkTextStyles.row === 'rgb(15, 23, 42)', `text rows should remain legible in dark theme: ${JSON.stringify(darkTextStyles)}`); assert(darkTextStyles.rowText === 'rgb(226, 232, 240)', `text row labels should remain legible in dark theme: ${JSON.stringify(darkTextStyles)}`); assert(darkTextStyles.input === 'rgb(15, 23, 42)', `text editor fields should use the dark theme: ${JSON.stringify(darkTextStyles)}`);
const selectedDarkBorder = await evaluate( command, `getComputedStyle(document.querySelector('.text-row.selected')).borderTopColor` );
assert( selectedDarkBorder === 'rgb(96, 165, 250)', `selected text rows should keep a visible highlight in dark theme: ${selectedDarkBorder}` ); await evaluate( command, `(() => { window.confirm = () => true; [...document.querySelectorAll('button')].find(button => button.textContent.trim() === 'New').click(); })()` ); await waitForEvaluation( command, `document.querySelectorAll('.text-row').length === 0 && !document.querySelector('.text-item')`, 'new graph clearing text selection' ); await evaluate(command, `document.querySelector('.add-text').click()`); await evaluate( command, `(() => { const preset = document.querySelector('[aria-label="Load 2d graph example"]'); preset.value = preset.options[1].value; preset.dispatchEvent(new Event('change', { bubbles: true })); })()` ); await waitForEvaluation( command, `document.querySelectorAll('.text-row').length === 0 && !document.querySelector('.text-item') && !document.querySelector('.text-controls')`, 'preset load clearing text selection' ); await evaluate( command, `(() => { const add = document.querySelector('.add-expression'); for (let index = 0; index < 20; index += 1) add.click(); })()` ); await waitForEvaluation(command, `document.querySelectorAll('article[data-expression-id]').length >= 20`, 'many expression rows'); await evaluate(command, `document.querySelector('.add-text').click()`);
const scrollMetrics = await evaluate( command, `(() => { const rows = document.querySelector('.rows'); rows.scrollTop = rows.scrollHeight; const rowPreview = document.querySelector('.text-row-preview'); const rowRect = rowPreview.getBoundingClientRect(); const editorRect = document.querySelector('.text-editor textarea').getBoundingClientRect(); const rowsRect = rows.getBoundingClientRect(); const editorVisible = editorRect.bottom > rowsRect.top && editorRect.top < rowsRect.bottom; rows.scrollTop = Math.max(0, rows.scrollTop + rowRect.top - rowsRect.top); const reachablePreview = rowPreview.getBoundingClientRect(); const reachableRows = rows.getBoundingClientRect(); return { scrollHeight: rows.scrollHeight, clientHeight: rows.clientHeight, previewVisible: reachablePreview.bottom > reachableRows.top && reachablePreview.top < reachableRows.bottom, editorVisible }; })()` );
assert(scrollMetrics.scrollHeight > scrollMetrics.clientHeight, 'expression and text rows should share a scrollable container'); assert(scrollMetrics.previewVisible, 'text source preview should be reachable after scrolling past many expressions'); assert(scrollMetrics.editorVisible, 'text source editor should be reachable after scrolling past many expressions'); await setTextSource(command, 'woof, *woof*, **woof**, ***woof***\n$$\\frac{1}{2 \\pi} \\int e^{-x^2} dx$$'); await waitForEvaluation(command, `Boolean(document.querySelector('.text-item mjx-mfrac'))`, 'display math');
const mathLayout = await evaluate(command, `(() => { const item = document.querySelector('.text-item').getBoundingClientRect(); const numerator = document.querySelector('.text-item mjx-num').getBoundingClientRect(); const line = document.querySelector('.text-item mjx-line').getBoundingClientRect(); const denominator = document.querySelector('.text-item mjx-den').getBoundingClientRect(); const integral = [...document.querySelectorAll('.text-item mjx-mo')].find(node => node.getAttribute('data-latex')?.endsWith('int'))?.querySelector('mjx-c')?.getBoundingClientRect(); return { item: item.toJSON(), numerator: numerator.toJSON(), line: line.toJSON(), denominator: denominator.toJSON(), integral: integral.toJSON() }; })()`);
assert(mathLayout.line.width > 10, 'the display fraction should have a visible bar'); assert(mathLayout.numerator.bottom < mathLayout.line.top, 'the fraction numerator should appear above its bar'); assert(mathLayout.denominator.top > mathLayout.line.bottom, 'the denominator should appear below its bar'); assert(mathLayout.integral.height > 30, 'the integral should use its full large-operator glyph'); assert(mathLayout.integral.top > mathLayout.item.top && mathLayout.integral.bottom < mathLayout.item.bottom, 'the integral should fit inside the text box'); await evaluate(command, `(() => { const input = document.querySelector('[data-text-color]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, '#d946ef'); input.dispatchEvent(new Event('input', { bubbles: true })); })()`); await waitForEvaluation( command, `(() => { const item = document.querySelector('.text-item'); const math = item?.querySelector('mjx-c'); return getComputedStyle(item).color === 'rgb(217, 70, 239)' && getComputedStyle(math).color === 'rgb(217, 70, 239)'; })()`, 'custom text and MathJax color' ); await clickExport(command); await waitForEvaluation(command, `window.__graphTextPngs.length === 7`, 'custom-color PNG export');
const customColorPixels = await evaluate(command, `(async () => { const bitmap = await createImageBitmap(await window.__graphTextPngBlobs[6]); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; const surface = document.querySelector('.graph-area .surface').getBoundingClientRect(); const box = document.querySelector('.text-item mjx-container').getBoundingClientRect(); const left = Math.max(0, Math.floor((box.left - surface.left) * canvas.width / surface.width)); const right = Math.min(canvas.width, Math.ceil((box.right - surface.left) * canvas.width / surface.width)); const top = Math.max(0, Math.floor((box.top - surface.top) * canvas.height / surface.height)); const bottom = Math.min(canvas.height, Math.ceil((box.bottom - surface.top) * canvas.height / surface.height)); let count = 0; for (let y = top; y < bottom; y += 1) for (let x = left; x < right; x += 1) { const index = (y * canvas.width + x) * 4; if (pixels[index] > 160 && pixels[index + 1] < 130 && pixels[index + 2] > 160) count += 1; } return count; })()`);
assert(customColorPixels > 10, `custom-colored LaTeX should appear in PNG export: ${customColorPixels}`); await setTextNumber(command, 'width', '80'); await setTextNumber(command, 'height', '40'); await setTextNumber(command, 'font-size', '32');
for (const [name, source] of [ ['f', '$f(x) = x^2$'], ['g', '$g(x) := 2(x - 3)^2 + 4$'], ['h', '$h(x) := -(x + 2)^2 + 1$'] ]) { await setTextSource(command, `$${name}(x)$`); await waitForEvaluation(command, `document.querySelector('.text-item mjx-math')?.getAttribute('data-latex') === ${JSON.stringify(`${name}(x)`)}`, `${name}(x) baseline`); const baselineIndex = await evaluate(command, `window.__graphTextPngs.length`); await clickExport(command); await waitForEvaluation(command, `window.__graphTextPngs.length === ${baselineIndex + 1}`, `${name}(x) baseline PNG`); await setTextSource(command, source); await waitForEvaluation( command, `(() => { const item = document.querySelector('.text-item'); const math = item?.querySelector('mjx-math'); const last = math?.querySelector('[data-latex]:last-child') ?? math?.lastElementChild; if (math?.getAttribute('data-latex') !== ${JSON.stringify(source.slice(1, -1))} || !last) return false; const box = item.getBoundingClientRect(); const end = last.getBoundingClientRect(); return end.right <= box.right - 3 && end.bottom <= box.bottom - 3 && end.left >= box.left + 3; })()`, `complete preview formula ${source}` ); await clickExport(command); await waitForEvaluation(command, `window.__graphTextPngs.length === ${baselineIndex + 2}`, `complete PNG formula ${source}`);
const changedPixels = await evaluate(command, `(async () => { const baseline = await createImageBitmap(await window.__graphTextPngBlobs[${baselineIndex}]); const bitmap = await createImageBitmap(await window.__graphTextPngBlobs[${baselineIndex + 1}]); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(baseline, 0, 0); const prior = context.getImageData(0, 0, canvas.width, canvas.height).data; const surface = document.querySelector('.graph-area .surface').getBoundingClientRect(); const item = document.querySelector('.text-item').getBoundingClientRect(); const left = Math.max(0, Math.floor((item.left - surface.left) * canvas.width / surface.width)); const right = Math.min(canvas.width, Math.ceil((item.right - surface.left) * canvas.width / surface.width)); const top = Math.max(0, Math.floor((item.top - surface.top) * canvas.height / surface.height)); const bottom = Math.min(canvas.height, Math.ceil((item.bottom - surface.top) * canvas.height / surface.height)); let count = 0; for (let y = top; y < bottom; y += 1) for (let x = left; x < right; x += 1) { const offset = (y * canvas.width + x) * 4; if (Math.abs(pixels[offset] - prior[offset]) + Math.abs(pixels[offset + 1] - prior[offset + 1]) + Math.abs(pixels[offset + 2] - prior[offset + 2]) > 45) count += 1; } return count; })()`);
assert(changedPixels > 20, `PNG should differ from the function-only label for ${source}: ${changedPixels}`); }
await evaluate(command, `document.querySelector('.color-auto input').click()`); await waitForEvaluation( command, `document.querySelector('.color-auto input').checked && document.querySelector('.text-item').style.color === ''`, 'graph theme text color restored' );
const legacySnapshot = { version: 1, name: 'Legacy text browser fixture', viewport: { x_min: -10, x_max: 10, y_min: -10, y_max: 10 }, equalAxisScale: true, appearance: { theme: 'light', showGrid: true, showMinorGrid: false, showXAxis: true, showYAxis: true, showAxisNumbers: true, xAxisLabel: '', yAxisLabel: '' }, expressions: [{ source: 'x', enabled: true, color: null, appearance: { lineWidth: 2.5, lineOpacity: 1, fillOpacity: 0.4, pointRadius: 5, pointOpacity: 1, pointStyle: 'circle' } }], texts: [{ id: 'legacy-browser-note', source: 'Legacy center', center: { x: 0, y: 0 }, widthPx: 240, heightPx: 96, fontSizePx: 16, align: 'left' }] };
const legacyPayload = gzipSync(Buffer.from(JSON.stringify(legacySnapshot))).toString('base64url'); const { targetId: legacyTargetId } = await browser.command('Target.createTarget', { url: 'about:blank' });
const { sessionId: legacySessionId } = await browser.command('Target.attachToTarget', { targetId: legacyTargetId, flatten: true });
const legacyCommand = (method, params = {}) => browser.command(method, params, legacySessionId); await legacyCommand('Page.enable'); await legacyCommand('Runtime.enable'); await legacyCommand('Page.navigate', { url: `${origin}/calc/2d#doc=${legacyPayload}` }); await waitForEvaluation( legacyCommand, `document.querySelector('.text-row-preview')?.textContent ?? ''`, 'legacy graph text snapshot load', 30_000, (value) => value === 'Legacy center' ); await evaluate(legacyCommand, `document.querySelector('.text-row').click()`);
const legacyLoad = await evaluate( legacyCommand, `(() => { const text = document.querySelector('.text-item').getBoundingClientRect(); const surface = document.querySelector('.graph-area .surface').getBoundingClientRect(); return { x: document.querySelector('[data-text-field="position-x"]').value, y: document.querySelector('[data-text-field="position-y"]').value, centerAnchor: document.querySelector('[data-text-anchor="middle-center"]').getAttribute('aria-pressed') === 'true', centerOffsetX: Math.abs((text.left + text.width / 2) - (surface.left + surface.width / 2)), centerOffsetY: Math.abs((text.top + text.height / 2) - (surface.top + surface.height / 2)) }; })()` );
assert(legacyLoad.x === '0' && legacyLoad.y === '0', `legacy center coordinates should migrate: ${JSON.stringify(legacyLoad)}`); assert(legacyLoad.centerAnchor, 'legacy text should load with the center-middle anchor'); assert(legacyLoad.centerOffsetX < 1 && legacyLoad.centerOffsetY < 1, `legacy text should keep its centered screen position: ${JSON.stringify(legacyLoad)}`); console.log(JSON.stringify({ ...safety, ...exportResult }));} finally { browser?.socket.close(); chromium?.kill(); vite?.kill(); await rm(browserProfile, { recursive: true, force: true });}
async function clickExport(command) { await evaluate( command, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === 'Export PNG').click()` );}
async function setTextSource(command, source) { await evaluate( command, `(() => { const editor = document.querySelector('.text-editor textarea'); Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(editor, ${JSON.stringify(source)}); editor.dispatchEvent(new Event('input', { bubbles: true })); })()` );}
async function setTextNumber(command, field, value) { await evaluate( command, `(() => { const input = document.querySelector('[data-text-field="${field}"]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, ${JSON.stringify(value)}); input.dispatchEvent(new Event('change', { bubbles: true })); })()` );}
async function clickAt(command, point) { await command('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y, button: 'none', buttons: 0 }); await command('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', buttons: 1, clickCount: 1 }); await command('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', buttons: 0 });}
async function dragPointer(command, start, end) { await command('Input.dispatchMouseEvent', { type: 'mouseMoved', x: start.x, y: start.y, button: 'none', buttons: 0 }); await command('Input.dispatchMouseEvent', { type: 'mousePressed', x: start.x, y: start.y, button: 'left', buttons: 1, clickCount: 1 }); await command('Input.dispatchMouseEvent', { type: 'mouseMoved', x: end.x, y: end.y, button: 'left', buttons: 1 }); await command('Input.dispatchMouseEvent', { type: 'mouseReleased', x: end.x, y: end.y, button: 'left', buttons: 0 });}
async function waitForEvaluation(command, expression, label, timeoutMs = 30_000, ready = Boolean) { const deadline = Date.now() + timeoutMs; let last;
while (Date.now() < deadline) { last = await evaluate(command, expression);
if (ready(last)) return last; await delay(100); }
throw new Error(`Timed out waiting for ${label}; last browser value: ${JSON.stringify(last)}`);}
async function findFreePort() { const server = createServer(); await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, host, resolveListen); }); const address = server.address();
if (!address || typeof address === 'string') throw new Error('Could not find an available port.'); const { port } = address; await new Promise((resolveClose, reject) => server.close((error) => (error ? reject(error) : resolveClose())) );
return port;}
async function waitForServer(url, child, output) { const deadline = Date.now() + 30_000;
while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`Vite exited: ${output.slice(-10).join('')}`);
try { if ((await fetch(url)).ok) return; } catch { // Vite is still starting. }
await delay(100); }
throw new Error(`Timed out waiting for ${url}: ${output.slice(-10).join('')}`);}
function collectOutput(child, output) { for (const stream of [child.stdout, child.stderr]) { stream?.on('data', (chunk) => output.push(chunk.toString())); }}