From 21c7ce026bd77da33c09ec63e7feb76d82428523 Mon Sep 17 00:00:00 2001 From: Nisconder <2129048448@qq.com> Date: Fri, 13 Nov 2026 13:47:36 +0800 Subject: [PATCH 1/3] Fix five Studio editing and compilation regressions (#3) --- server/server.mjs | 5 +- src/js/main.js | 18 +++- src/js/preview.js | 55 ++++++++--- src/js/visual-math.js | 30 +++++- src/js/workspace.js | 69 +++++++++++++- tests/image-label-regression.test.mjs | 93 ++++++++++++++++++ tests/preview-integration.test.mjs | 59 ++++++++++++ tests/preview.test.mjs | 66 ++++++++++++- tests/server.test.mjs | 7 +- tests/visual-math-ui.test.mjs | 49 ++++++++++ tests/workspace-archive-types.test.mjs | 126 +++++++++++++++++++++++++ 11 files changed, 550 insertions(+), 27 deletions(-) create mode 100644 tests/image-label-regression.test.mjs create mode 100644 tests/preview-integration.test.mjs create mode 100644 tests/workspace-archive-types.test.mjs diff --git a/server/server.mjs b/server/server.mjs index 6319f88..b201746 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -162,7 +162,7 @@ export function createWorkspaceServer({ if (!delivered && job.active) { job.state = 'cancelled'; job.controller.abort(); } }); json(202, {id: job.id}); - Promise.resolve().then(async () => { + job.completion = Promise.resolve().then(async () => { const engines = await getEngines(); if (job.controller.signal.aborted) return; const result = await compiler(payload.project, {kind: payload.kind, mainPath: payload.mainPath, signal: job.controller.signal, engines}); @@ -185,6 +185,9 @@ export function createWorkspaceServer({ if (previewActive?.job === job) previewActive.controller.abort(); if (job.active) { job.state = 'cancelled'; job.controller.abort(); + // Acknowledging cancellation also means the compiler slot is + // free; process termination and temporary-file cleanup can lag abort. + await job.completion; } json(200, {id: job.id, state: job.state}); return; diff --git a/src/js/main.js b/src/js/main.js index a195611..e11f91a 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -229,6 +229,15 @@ function insertBlockFormula() { openFormulaModal('block'); } +// 标签是引用标识符,不能用正文转义规则改写下划线等合法字符。 +function normalizeImageLabel(value) { + const label = String(value ?? '').trim(); + if (/[\\{}\[\],%#$&^~*\s\u0000-\u001f\u007f]/.test(label)) { + throw new Error('图片标签不能包含空白、括号、反斜杠或 LaTeX 控制字符'); + } + return label; +} + // 插入图片 function insertImage(options) { const supplied = options && typeof options.imagePath === 'string'; @@ -237,8 +246,10 @@ function insertImage(options) { if (!imagePath) return; const caption = supplied ? options.caption : prompt('请输入图片标题:', '图片标题'); if (caption === null) return; - const label = supplied ? options.label : prompt('请输入图片标签(用于引用):', 'fig:example'); + let label = supplied ? options.label : prompt('请输入图片标签(用于引用):', 'fig:example'); if (label === null) return; + try { label = normalizeImageLabel(label); } + catch (error) { updateStatus(error.message); return; } const img = document.createElement('img'); img.src = imagePath; @@ -1002,7 +1013,8 @@ function convertNodeToLatex(node) { case 'img': { const imgPath = node.getAttribute('src') || ''; const caption = node.getAttribute('data-caption'); - const label = node.getAttribute('data-label'); + const rawLabel = node.getAttribute('data-label'); + const label = node.getAttribute('data-label-tex') === 'true' ? rawLabel : normalizeImageLabel(rawLabel); const width = node.getAttribute('data-width'); const placement = node.getAttribute('data-placement'); latex = '\\begin{figure}' + (placement === '' ? '' : '[' + (placement || 'htbp') + ']') + '\n'; @@ -1012,7 +1024,7 @@ function convertNodeToLatex(node) { if (caption || node.getAttribute('data-has-caption') === 'true') { latex += ' \\caption{' + (node.getAttribute('data-caption-tex') === 'true' ? caption || '' : escapeLatex(caption || '')) + '}\n'; } - if (label) latex += ' \\label{' + (node.getAttribute('data-label-tex') === 'true' ? label : escapeLatex(label)) + '}\n'; + if (label) latex += ' \\label{' + label + '}\n'; latex += '\\end{figure}' + separator; break; } diff --git a/src/js/preview.js b/src/js/preview.js index b1a10c7..2e26331 100644 --- a/src/js/preview.js +++ b/src/js/preview.js @@ -41,7 +41,10 @@ this.result = null; this.stale = false; this.generation = 0; + this.connectionGeneration = 0; this.jobId = null; + this.submission = null; + this.cancellation = null; this.abort = null; this.disposed = false; this.preview = {state: 'empty', message: '', url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100}; @@ -49,7 +52,7 @@ this.previewAbort = null; this.previewTask = Promise.resolve(false); } - get busy() { return this.phase === 'running'; } + get busy() { return this.phase === 'running' || Boolean(this.cancellation); } emit() { if (!this.disposed) this.onChange(this); } async responseJson(response) { let body; @@ -60,6 +63,8 @@ headers() { return {'X-VisualLaTeX-Token': this.token}; } async connect() { if (this.busy || this.disposed) return false; + const connection = ++this.connectionGeneration, generation = this.generation; + const current = () => !this.disposed && !this.busy && generation === this.generation && connection === this.connectionGeneration; if (this.protocol !== 'http:' && this.protocol !== 'https:') { this.phase = 'unavailable'; this.message = '编译预览需要本机服务:在项目目录运行 node server/server.mjs,再打开终端显示的网址。'; @@ -67,6 +72,7 @@ } try { const body = await this.responseJson(await this.fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'})); + if (!current()) return false; if (!body.token || !body.engines) throw new Error('当前页面没有连接到 VisualLaTeX 编译服务。'); this.token = body.token; this.engines = body.engines; @@ -74,6 +80,7 @@ this.message = '选择主文件后编译;项目文件仅发送到本机服务。'; this.emit(); return true; } catch (error) { + if (!current()) return false; this.token = null; this.phase = 'unavailable'; this.message = '无法连接编译服务。请运行 node server/server.mjs 并打开终端网址。' + error.message; @@ -88,21 +95,37 @@ this.emit(); } fresh(request) { return Boolean(this.result && this.result.key === requestKey(request)); } - async deleteJob(id) { - if (!id || !this.token) return; - try { await this.fetch('/api/jobs/' + encodeURIComponent(id), {method: 'DELETE', headers: this.headers(), credentials: 'same-origin', keepalive: true}); } + async deleteJob(id, token = this.token) { + if (!id || !token) return; + try { await this.fetch('/api/jobs/' + encodeURIComponent(id), {method: 'DELETE', headers: {'X-VisualLaTeX-Token': token}, credentials: 'same-origin', keepalive: true}); } catch (_) { /* Server timeout/cleanup still bounds an unreachable job. */ } } + cancelSubmission(submission) { + return submission.cancellation ||= submission.promise.then(job => { + if (typeof job?.id === 'string' && /^[a-zA-Z0-9_-]+$/.test(job.id)) return this.deleteJob(job.id, submission.token); + }, () => {}); + } async cancel() { + if (this.cancellation) return this.cancellation; const id = this.jobId; - this.generation++; + const submission = this.submission, wasBusy = this.busy; + const generation = ++this.generation; this.abort?.abort(); this.abort = null; this.jobId = null; this.stopPreview(); - if (this.busy) { this.phase = 'cancelled'; this.message = '已取消本次编译。'; } + if (wasBusy) { this.phase = 'cancelled'; this.message = '已取消本次编译。'; } + if (!submission && !id) { this.emit(); return; } + const cleanup = submission ? this.cancelSubmission(submission) : this.deleteJob(id); + this.cancellation = cleanup; + if (wasBusy) { this.phase = 'cancelling'; this.message = '正在取消本次编译…'; } this.emit(); - await this.deleteJob(id); + try { await cleanup; } + finally { + if (this.cancellation === cleanup) this.cancellation = null; + if (wasBusy && generation === this.generation) { this.phase = 'cancelled'; this.message = '已取消本次编译。'; } + this.emit(); + } } clear() { this.cancel(); @@ -215,14 +238,19 @@ this.message = request.kind === 'postscript' ? '正在渲染 PostScript…' : '正在编译 LaTeX…'; this.log = ''; this.emit(); - let job; + if (generation !== this.generation) return false; + let job, submission; try { - job = await this.responseJson(await this.fetch('/api/compile', { + // Keep the creation response readable after cancellation: otherwise + // a server job can start before fetch exposes the id needed to delete it. + submission = {token: this.token, promise: this.fetch('/api/compile', { method: 'POST', headers: {...this.headers(), 'Content-Type': 'application/json'}, - credentials: 'same-origin', body: JSON.stringify(request), signal: abort.signal - })); + credentials: 'same-origin', body: JSON.stringify(request) + }).then(response => this.responseJson(response))}; + this.submission = submission; + job = await submission.promise; if (typeof job.id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(job.id)) throw new Error('编译服务返回的任务编号无效。'); - if (generation !== this.generation) { await this.deleteJob(job.id); return false; } + if (generation !== this.generation) { await this.cancelSubmission(submission); return false; } this.jobId = job.id; const started = Date.now(); for (;;) { @@ -273,9 +301,10 @@ // A failed polling/download request does not stop the engine. // Release the known job before discarding its id, while keeping // the original error visible if cleanup itself cannot connect. - await this.deleteJob(job?.id); + if (submission) await this.cancelSubmission(submission); return false; } finally { + if (this.submission === submission) this.submission = null; if (generation === this.generation) { this.abort = null; this.jobId = null; } } } diff --git a/src/js/visual-math.js b/src/js/visual-math.js index cdfda28..5682a8f 100644 --- a/src/js/visual-math.js +++ b/src/js/visual-math.js @@ -168,7 +168,7 @@ const field = document.getElementById('formulaMathField'); if (!field || !root.customElements?.get('math-field')) return false; const parts = split(source); - session = {parts, dirty:false, values:[], field, node}; + session = {parts, dirty:false, values:[], field, node, advancedSource:source}; const error = document.getElementById('formulaError'); error.textContent = ''; field.readOnly = !safe(source); field.value = safe(source) ? mathValue(parts,node?.getAttribute('data-environment')) : ''; @@ -182,7 +182,14 @@ label.textContent = (entry.kind === 'label' ? '公式标签' : '显示编号')+(parts.rows.length > 1 ? '(第 '+(entry.row+1)+' 行)' : ''); const input = document.createElement('input'); input.value = entry.value; input.setAttribute('aria-label',label.textContent); - input.addEventListener('input',()=> { if(session) session.values[i]=input.value; }); + input.addEventListener('input',()=> { + if (!session) return; + session.values[i] = input.value; + // Advanced edits rebuild the session from this source. Keep + // it current so they cannot resurrect old labels or tags. + try { syncAdvancedInput(); error.textContent = ''; } + catch (problem) { error.textContent = problem.message; } + }); label.appendChild(input); metadata.appendChild(label); }); if (focus) field.focus(); @@ -193,6 +200,11 @@ const {parts,field,values,dirty} = session; return combine(parts,field.value,values,dirty); } + function syncAdvancedInput() { + const source = value(); + document.getElementById('formulaInput').value = source; + if (session) session.advancedSource = source; + } function initialize() { if (!root.MathfieldElement) return; root.MathfieldElement.fontsDirectory = './fonts'; @@ -203,10 +215,22 @@ field.addEventListener('input',()=> { if (!session) return; session.dirty = true; session.advanced = false; - try { document.getElementById('formulaInput').value = value(); } catch (_) { /* Commit reports row validation. */ } + try { syncAdvancedInput(); } catch (_) { /* Commit reports row validation. */ } }); document.getElementById('formulaInput').addEventListener('input',()=> { if (!session) return; + try { + session.parts.metadata.forEach((entry,i) => { + const next = session.values[i]; + if (entry.kind !== 'comment' && next !== undefined && next !== entry.value) validateValue(entry.kind,next); + }); + } catch (problem) { + // Do not replace unsaved invalid fields with an older source + // snapshot. Let the user correct them before changing views. + document.getElementById('formulaInput').value = session.advancedSource; + document.getElementById('formulaError').textContent = problem.message + ' 请先修正标签或编号,再编辑高级输入。'; + return; + } const source=document.getElementById('formulaInput').value; open(source,session.node,false); session.advanced=true; diff --git a/src/js/workspace.js b/src/js/workspace.js index d627c6c..a978b8a 100644 --- a/src/js/workspace.js +++ b/src/js/workspace.js @@ -115,6 +115,54 @@ function workspaceFile(path, bytes) { return {path, kind: 'binary', base64: btoa(binary)}; } +function workspaceArchiveFiles(entries, fileTypes) { + // Overrides are [index, type, byteLength, pathCRC32, contentCRC32]. Binding + // them to both path and bytes makes stale comments from external repacking + // harmless; indexes avoid repeating up to 500 long paths in a 64 KiB comment. + const types = new Map(); + const uint32 = value => Number.isInteger(value) && value >= 0 && value <= 0xffffffff; + if (Array.isArray(fileTypes) && fileTypes.length <= ProjectModel.MAX_FILES) { + for (const record of fileTypes) { + if (!Array.isArray(record) || record.length !== 5 || !Number.isInteger(record[0]) || + record[0] < 0 || record[0] >= entries.length || types.has(record[0]) || + !['utf-8', 'latin1', 'binary'].includes(record[1]) || + !Number.isInteger(record[2]) || record[2] < 0 || record[2] > ProjectModel.MAX_TOTAL_BYTES || + !uint32(record[3]) || !uint32(record[4])) { + types.clear(); + break; + } + types.set(record[0], record); + } + } + return entries.map(({path, bytes}, index) => { + const detected = workspaceFile(path, bytes); + const record = types.get(index); + if (!record || record[2] !== bytes.length || + record[3] !== ProjectArchive.crc32(new TextEncoder().encode(path)) || + record[4] !== ProjectArchive.crc32(bytes)) return detected; + let restored; + try { + if (record[1] === 'binary') { + let binary = ''; + for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); + restored = {path, kind: 'binary', base64: btoa(binary)}; + } else { + let text; + if (record[1] === 'utf-8') text = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(bytes); + else { + text = ''; + for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode(...bytes.subarray(i, i + 8192)); + } + restored = {path, kind: 'text', text, encoding: record[1]}; + } + // Metadata is a hint, never permission for lossy decoding. + const encoded = workspaceBytes(restored); + if (encoded.length === bytes.length && encoded.every((byte, offset) => byte === bytes[offset])) return restored; + } catch (_) { /* Invalid text metadata falls back to normal byte detection. */ } + return detected; + }); +} + // Locate the clicked protected token within its own source unit, not the first // identical citation elsewhere in the document. Offsets match textarea newlines. function locateWorkspaceToken(editor, target, source) { @@ -275,10 +323,20 @@ class WorkspaceController { this.sync(); return this.model.list().map(file => ({path: file.path, bytes: workspaceBytes(file)})); } - exportComment() { + exportComment(entries = this.exportEntries()) { // ZIP comments keep project settings out of the user's file namespace. - return JSON.stringify({format: 'visual-workspace', version: 1, name: this.model.name, - mainPath: this.model.mainPath, activePath: this.model.activePath}); + const metadata = {format: 'visual-workspace', version: 1, name: this.model.name, + mainPath: this.model.mainPath, activePath: this.model.activePath}; + const fileTypes = []; + this.model.list().forEach((file, index) => { + const bytes = entries[index].bytes; + const detected = workspaceFile(file.path, bytes); + if (detected.kind === file.kind && detected.encoding === file.encoding) return; + fileTypes.push([index, file.kind === 'binary' ? 'binary' : file.encoding, bytes.length, + ProjectArchive.crc32(new TextEncoder().encode(file.path)), ProjectArchive.crc32(bytes)]); + }); + if (fileTypes.length) metadata.fileTypes = fileTypes; + return JSON.stringify(metadata); } } @@ -583,7 +641,8 @@ function initWorkspace() { } bind('btnDownload', () => { app.sync(); const file = app.model.get(); if (file) download(workspaceBytes(file), file.path.split('/').pop()); }); bind('btnExportProject', () => { - const bytes = ProjectArchive.writeZip(app.exportEntries(), {comment: app.exportComment()}); + const entries = app.exportEntries(); + const bytes = ProjectArchive.writeZip(entries, {comment: app.exportComment(entries)}); download(bytes, (app.model.name || 'project').replace(/[\\/:*?"<>|]/g, '_') + '.zip', 'application/zip'); notify('项目 ZIP 已导出,包含所有源码和资源文件'); }); @@ -652,7 +711,7 @@ function initWorkspace() { if (value && value.format === 'visual-workspace' && value.version === 1 && typeof value.name === 'string' && value.name.trim() && value.name.length <= 200) metadata = value; } catch (_) { /* ZIP comments from other tools are not project metadata. */ } - const files = archive.files.map(entry => workspaceFile(entry.path, entry.bytes)); + const files = workspaceArchiveFiles(archive.files, metadata?.fileTypes); const main = files.find(item => item.kind === 'text' && item.path === 'main.tex') || files.find(item => item.kind === 'text' && /\.(tex|ps|eps)$/i.test(item.path)) || files.find(item => item.kind === 'text'); const first = main || files[0]; candidate = new ProjectModel({name: metadata ? metadata.name : file.name.replace(/\.zip$/i, ''), files, diff --git a/tests/image-label-regression.test.mjs b/tests/image-label-regression.test.mjs new file mode 100644 index 0000000..a5ddb3b --- /dev/null +++ b/tests/image-label-regression.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import test from 'node:test'; +import {createDocument, Element} from './helpers/dom.mjs'; + +function fixture() { + const document = createDocument(); + const createElement = document.createElement; + document.createElement = tag => { + const node = createElement(tag); + for (const name of ['src', 'alt']) Object.defineProperty(node, name, { + get() { return this.getAttribute(name); }, + set(value) { this.setAttribute(name, value); }, + }); + return node; + }; + const editor = new Element('div'); + const statuses = []; + const context = vm.createContext({document, TextEncoder, Node: {TEXT_NODE: 3, ELEMENT_NODE: 1}, + __editor: editor, __statuses: statuses}); + for (const name of ['main.js', 'project-model.js', 'visual-references.js']) { + vm.runInContext(fs.readFileSync(new URL('../' + name, import.meta.url), 'utf8'), context); + } + // Only cursor placement and persistence notifications are outside this integration check. + vm.runInContext(`editorArea = __editor; + insertNodeAtCursor = node => Boolean(editorArea.appendChild(node)); + updatePreview = () => {}; saveDraft = () => true; + updateStatus = message => __statuses.push(message);`, context); + return {context, editor, statuses}; +} + +test('inserted image identifiers remain selectable and usable by the reference editor', () => { + for (const label of ['fig:my_plot', '图:结果_2026.1-甲']) { + const {context} = fixture(); + context.insertImage({imagePath: 'plot.png', caption: 'Rate 50% & value_1', label}); + const tex = context.domToLatex(); + assert.ok(tex.includes('\\label{' + label + '}')); + assert.ok(tex.includes(String.raw`\caption{Rate 50\% \& value\_1}`)); + const model = new context.ProjectModel({files: [{path: 'main.tex', kind: 'text', text: tex}], activePath: 'main.tex'}); + const choices = context.VisualReferences.getChoices('reference', model); + assert.equal(choices.length, 1); + assert.equal(choices[0].value, label); + assert.equal(context.VisualReferences.rewrite(String.raw`\ref{old}`, {key: choices[0].value}), '\\ref{' + label + '}'); + } +}); + +test('invalid new image labels leave the document intact and report the input error', () => { + for (const label of ['fig:{bad}', String.raw`fig:bad\name`, 'fig:two words', 'fig:bad%name', 'fig:bad#name', 'fig:bad,name', 'fig:bad*name']) { + const {context, editor, statuses} = fixture(); + editor.innerHTML = '
Keep this document.
'; + const before = context.domToLatex(); + context.insertImage({imagePath: 'plot.png', caption: 'Plot', label}); + assert.equal(context.domToLatex(), before); + assert.equal(editor.querySelectorAll('img').length, 0); + assert.match(statuses.at(-1), /标签/); + } +}); + +test('empty image labels stay optional while surrounding whitespace is normalized', () => { + for (const label of ['', ' ', ' fig:my_plot ']) { + const {context} = fixture(); + context.insertImage({imagePath: 'plot.png', caption: 'Plot', label}); + const tex = context.domToLatex(); + if (label.trim()) assert.ok(tex.includes('\\label{' + label.trim() + '}')); + else assert.doesNotMatch(tex, /\\label\{/); + } +}); + +test('image export rejects invalid legacy user labels instead of emitting malformed TeX', () => { + const {context, editor} = fixture(); + editor.innerHTML = '
';
+ assert.throws(() => context.domToLatex(), /标签/);
+});
+
+test('editing an imported image preserves its original TeX label and caption', () => {
+ for (const label of ['fig:my_plot', String.raw`fig:\customkey`]) {
+ const {context, editor} = fixture();
+ const tex = String.raw`\documentclass{article}
+\usepackage{graphicx}
+\newcommand{\customkey}{imported}
+\begin{document}
+\begin{figure}\includegraphics{old.png}\caption{A \textbf{caption}}\label{${label}}\end{figure}
+\end{document}`;
+ context.applyImportedTex(tex, 'imported.tex');
+ assert.equal(context.domToLatex(), tex);
+ editor.querySelector('img').setAttribute('src', 'new.png');
+ const edited = context.domToLatex();
+ assert.ok(edited.includes(String.raw`\includegraphics{new.png}`));
+ assert.ok(edited.includes(String.raw`\caption{A \textbf{caption}}`));
+ assert.ok(edited.includes('\\label{' + label + '}'));
+ }
+});
diff --git a/tests/preview-integration.test.mjs b/tests/preview-integration.test.mjs
new file mode 100644
index 0000000..e92c991
--- /dev/null
+++ b/tests/preview-integration.test.mjs
@@ -0,0 +1,59 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {createWorkspaceServer} from '../server.mjs';
+
+const code = readFileSync(new URL('../preview.js', import.meta.url), 'utf8');
+const PDF = Buffer.from('%PDF-1.4\nfixture\n%%EOF');
+const engines = {latex: {available: true, engine: 'tectonic', label: 'Tectonic'}, postscript: {available: false}};
+const request = {kind: 'latex', mainPath: 'main.tex', project: {format: 'visual-workspace', version: 1,
+ name: 'Example', mainPath: 'main.tex', activePath: 'main.tex', files: [{path: 'main.tex', kind: 'text', text: 'source'}]}};
+
+test('cancelling before the POST response arrives stops the real server job and permits a new compile', {timeout: 5000}, async t => {
+ let controller, firstSignal, cancellation, releaseEngine;
+ let compilerCalls = 0;
+ let reportAbort;
+ const engineAborted = new Promise(resolve => { reportAbort = resolve; });
+ const calls = [];
+ const server = createWorkspaceServer({enginesProvider: async () => engines, compiler: async (_, {signal}) => {
+ if (++compilerCalls > 1) return {pdf: PDF, log: 'done', engine: 'Tectonic'};
+ firstSignal = signal;
+ const running = new Promise(resolve => {
+ releaseEngine = () => resolve({pdf: PDF, log: 'cleanup', engine: 'Tectonic'});
+ signal.addEventListener('abort', reportAbort, {once: true});
+ });
+ // The server has sent its 202, but real fetch has not delivered the job id yet.
+ assert.equal(controller.jobId, null);
+ cancellation = controller.cancel();
+ return running;
+ }});
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
+ t.after(async () => {
+ releaseEngine?.();
+ controller?.destroy();
+ await new Promise(resolve => { server.close(resolve); server.closeAllConnections(); });
+ });
+ const base = 'http://127.0.0.1:' + server.address().port;
+ const fetcher = (url, init = {}) => { calls.push({url, init}); return fetch(base + url, init); };
+ const context = vm.createContext({Blob, AbortController, setTimeout, clearTimeout, fetch: fetcher,
+ URL: {createObjectURL: () => 'blob:fixture', revokeObjectURL() {}}, location: {protocol: 'http:'}});
+ vm.runInContext(code, context);
+ controller = new context.CompilationController({getRequest: () => structuredClone(request), fetch: fetcher, pollInterval: 1});
+ assert.equal(await controller.connect(), true);
+ const compiling = controller.compile();
+ await engineAborted;
+ assert.equal(controller.busy, true, 'keep compilation disabled during engine shutdown');
+ assert.equal(await controller.compile(), false, 'do not submit a new job before cancellation completes');
+ releaseEngine();
+ assert.equal(await compiling, false);
+ await cancellation;
+ assert.equal(controller.phase, 'cancelled');
+ assert.equal(firstSignal.aborted, true, 'cancellation reaches the engine even without an initial job id');
+ assert.equal(calls.filter(call => call.init.method === 'DELETE').length, 1);
+ assert.equal(controller.result, null);
+ assert.equal(await controller.compile(), true, 'the cancelled job no longer blocks the compiler');
+ await controller.previewTask;
+ assert.equal(compilerCalls, 2);
+ assert.equal(controller.phase, 'succeeded');
+});
diff --git a/tests/preview.test.mjs b/tests/preview.test.mjs
index 9defde3..0e9fefa 100644
--- a/tests/preview.test.mjs
+++ b/tests/preview.test.mjs
@@ -59,6 +59,66 @@ test('file mode makes no requests and explains how to enable local compilation',
assert.equal(f.calls.length, 0);
});
+test('late reconnect success or failure cannot interrupt a running compilation', async () => {
+ for (const status of [200, 503]) {
+ const reconnect = deferred(), progress = deferred(), polling = deferred();
+ let statusRequests = 0;
+ const f = fixture({route: async (url, init) => {
+ if (url === '/api/status' && ++statusRequests > 1) return reconnect.promise;
+ if (url === '/api/jobs/job-1' && init.method !== 'DELETE') { polling.resolve(); return progress.promise; }
+ }});
+ await f.controller.connect();
+ const connecting = f.controller.connect();
+ const compiling = f.controller.compile();
+ await polling.promise;
+ reconnect.resolve(status === 200 ? json({token: 'late-token', engines}) : json({error: 'Disconnected'}, status));
+ assert.equal(await connecting, false);
+ assert.equal(f.controller.busy, true);
+ assert.equal(f.controller.token, 'test-token');
+ assert.equal(await f.controller.compile(), false, 'a second compile remains blocked');
+ assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 1);
+ progress.resolve(json({state: 'succeeded', pdfUrl: '/api/jobs/job-1/pdf'}));
+ assert.equal(await compiling, true, 'the original result still reaches the preview');
+ f.controller.destroy();
+ }
+});
+
+test('a newer connection wins over an older response or error', async () => {
+ for (const status of [200, 503]) {
+ const older = deferred();
+ let requests = 0;
+ const f = fixture({route: async url => {
+ if (url === '/api/status') return ++requests === 1 ? older.promise : json({token: 'new-token', engines});
+ }});
+ const pending = f.controller.connect();
+ assert.equal(await f.controller.connect(), true);
+ older.resolve(status === 200 ? json({token: 'old-token', engines: {}}) : json({error: 'Old request failed'}, status));
+ assert.equal(await pending, false);
+ assert.equal(f.controller.token, 'new-token');
+ assert.equal(f.controller.engines.latex.available, true);
+ assert.equal(f.controller.phase, 'idle');
+ f.controller.destroy();
+ }
+});
+
+test('clearing or destroying a controller invalidates pending connection replies', async () => {
+ for (const action of ['clear', 'destroy']) {
+ for (const status of [200, 503]) {
+ const response = deferred();
+ const f = fixture({route: async url => url === '/api/status' ? response.promise : undefined});
+ const connecting = f.controller.connect();
+ f.controller[action]();
+ const phase = f.controller.phase, message = f.controller.message;
+ response.resolve(status === 200 ? json({token: 'late-token', engines}) : json({error: 'Late failure'}, status));
+ assert.equal(await connecting, false);
+ assert.equal(f.controller.token, null);
+ assert.equal(f.controller.phase, phase);
+ assert.equal(f.controller.message, message);
+ f.controller.destroy();
+ }
+ }
+});
+
test('successful compilation sends an immutable project snapshot and exports the current PDF', async () => {
const f = fixture();
const original = structuredClone(f.request);
@@ -152,9 +212,13 @@ test('cancellation before POST resolves deletes a late job id without starting a
await f.controller.connect();
const running = f.controller.compile();
await ready.promise;
- await f.controller.cancel();
+ const cancelling = f.controller.cancel();
+ assert.equal(f.controller.busy, true, 'wait for the submission and cancellation acknowledgement');
+ assert.equal(await f.controller.compile(), false, 'do not race a new job against pending cancellation');
response.resolve(json({id: 'late-job'}, 202));
+ await cancelling;
assert.equal(await running, false);
+ assert.equal(f.controller.busy, false);
assert.ok(f.calls.some(call => call.url === '/api/jobs/late-job' && call.init.method === 'DELETE'));
assert.equal(f.created.length, 0);
});
diff --git a/tests/server.test.mjs b/tests/server.test.mjs
index 083b405..a233a5f 100644
--- a/tests/server.test.mjs
+++ b/tests/server.test.mjs
@@ -125,9 +125,14 @@ test('only one job runs at once and cancellation cannot be overwritten by a late
const f = await fixture(t, {compiler: (_project, settings) => { signal = settings.signal; return new Promise(resolve => { finish = resolve; }); }});
const {id} = await (await f.compile()).json();
assert.equal((await f.compile()).status, 409);
- assert.equal((await f.call('/api/jobs/' + id, {method: 'DELETE'})).status, 200);
+ let acknowledged = false;
+ const aborted = new Promise(resolve => signal.addEventListener('abort', resolve, {once: true}));
+ const cancelling = f.call('/api/jobs/' + id, {method: 'DELETE'}).then(response => { acknowledged = true; return response; });
+ await aborted;
assert.equal(signal.aborted, true);
+ assert.equal(acknowledged, false, 'wait for the engine to release the compiler slot');
finish({pdf: PDF, log: 'late', engine: 'tectonic'});
+ assert.equal((await cancelling).status, 200);
assert.equal((await f.completed(id)).state, 'cancelled');
assert.equal((await f.call('/api/jobs/' + id + '/pdf')).status, 409);
});
diff --git a/tests/visual-math-ui.test.mjs b/tests/visual-math-ui.test.mjs
index 11d4d67..2dbd8f2 100644
--- a/tests/visual-math-ui.test.mjs
+++ b/tests/visual-math-ui.test.mjs
@@ -248,3 +248,52 @@ test('formula labels in the reference sidebar open their formula editor while re
assert.doesNotMatch(f.app.model.get().text, /eq:second/);
assert.equal(f.app.mode, 'visual');
});
+
+test('metadata edits stay synchronized before subsequent advanced mathematical edits', async () => {
+ const f = await visual(String.raw`\begin{equation}x=1\label{eq:old}\tag{A 1}\end{equation}`);
+ await open(f);
+ const fields = f.node('formulaMetadata').querySelectorAll('input');
+ fields[0].value = 'eq:new'; await f.emit(fields[0], 'input');
+ fields[1].value = 'B 2'; await f.emit(fields[1], 'input');
+ assert.equal(f.node('formulaInput').value, String.raw`x=1\label{eq:new}\tag{B 2}`);
+ await f.input('formulaInput', f.node('formulaInput').value.replace('x=1', 'x=2'));
+ await f.click('btnInsertFormula');
+ assert.equal(formula(f).getAttribute('data-formula'), String.raw`x=2\label{eq:new}\tag{B 2}`);
+ assert.match(f.app.model.get().text, /x=2\\label\{eq:new\}\\tag\{B 2\}/);
+ assert.doesNotMatch(f.app.model.get().text, /eq:old|A 1/);
+});
+
+test('cleared metadata stays removed when switching from graphical to advanced editing', async () => {
+ const f = await visual(String.raw`$x=1\label{eq:old}\tag{A}$`);
+ await open(f);
+ f.node('formulaMathField').value = 'x=2';
+ await f.emit(f.node('formulaMathField'), 'input');
+ for (const field of f.node('formulaMetadata').querySelectorAll('input')) {
+ field.value = ''; await f.emit(field, 'input');
+ }
+ assert.equal(f.node('formulaInput').value, 'x=2');
+ await f.input('formulaInput', f.node('formulaInput').value + '+1');
+ await f.click('btnInsertFormula');
+ assert.equal(formula(f).getAttribute('data-formula'), 'x=2+1');
+ assert.doesNotMatch(f.app.model.get().text, /\\label|\\tag/);
+});
+
+test('invalid metadata cannot be silently discarded by an advanced edit', async () => {
+ const f = await visual(String.raw`$x=1\label{eq:old}\tag{A}$`);
+ const original = f.app.model.get().text;
+ await open(f);
+ const fields = f.node('formulaMetadata').querySelectorAll('input');
+ fields[1].value = 'B%'; await f.emit(fields[1], 'input');
+ fields[0].value = ''; await f.emit(fields[0], 'input');
+ await f.input('formulaInput', f.node('formulaInput').value.replace('x=1', 'x=2'));
+ await f.click('btnInsertFormula');
+ assert.equal(f.app.model.get().text, original);
+ assert.equal(f.node('formulaModal').classList.contains('show'), true);
+ assert.ok(f.node('formulaError').textContent);
+ assert.equal(f.node('formulaMetadata').querySelectorAll('input')[0].value, '');
+ assert.equal(f.node('formulaMetadata').querySelectorAll('input')[1].value, 'B%');
+ fields[1].value = 'B'; await f.emit(fields[1], 'input');
+ await f.input('formulaInput', f.node('formulaInput').value.replace('x=1', 'x=2'));
+ await f.click('btnInsertFormula');
+ assert.equal(formula(f).getAttribute('data-formula'), String.raw`x=2\tag{B}`);
+});
diff --git a/tests/workspace-archive-types.test.mjs b/tests/workspace-archive-types.test.mjs
new file mode 100644
index 0000000..2cff044
--- /dev/null
+++ b/tests/workspace-archive-types.test.mjs
@@ -0,0 +1,126 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {fixture} from './helpers/workspace-fixture.mjs';
+
+const encode = text => new TextEncoder().encode(text);
+const text = (path, source, encoding = 'utf-8') => ({path, kind: 'text', text: source, encoding});
+const binary = (path, bytes) => ({path, kind: 'binary', base64: Buffer.from(bytes).toString('base64')});
+
+async function exportProject(f) {
+ const count = f.blobs.length;
+ await f.click('btnExportProject');
+ assert.equal(f.blobs.length, count + 1, f.node('statusText').textContent);
+ return new Uint8Array(await f.blobs.at(-1).arrayBuffer());
+}
+
+async function importProject(f, bytes) {
+ f.node('importProject').files = [{name: 'round-trip.zip', size: bytes.length,
+ arrayBuffer: async () => bytes.slice().buffer}];
+ await f.emit(f.node('importProject'), 'change');
+}
+
+test('ZIP round-trip retains Latin-1 TeX, BibTeX and custom text with the original active and main file', async () => {
+ const f = await fixture();
+ await f.input('sourceArea', '\\documentclass{article}\r\n\\begin{document}\r\ncafé\r\n\\end{document}\r\n');
+ f.prompts.push('latin1');
+ await f.click('btnEncoding');
+ f.app.model.add(text('legacy.bib', '@book{cafe,title={Café}}\r\n', 'latin1'));
+ // These Latin-1 bytes also form valid UTF-8: detection alone changes the text.
+ f.app.model.add(text('custom.source', 'Two Latin-1 characters: \u00c3\u00a9', 'latin1'));
+ const expected = f.app.model.serialize();
+ await importProject(f, await exportProject(f));
+ assert.equal(f.app.model.serialize(), expected);
+ assert.equal(f.app.model.mainPath, 'main.tex');
+ assert.equal(f.app.model.activePath, 'main.tex');
+ assert.equal(f.node('assetPanel').hidden, true);
+ await f.click('modeSource');
+ assert.equal(f.node('sourcePanel').hidden, false);
+ assert.equal(f.node('btnEncoding').textContent, 'LATIN1');
+});
+
+test('ZIP round-trip preserves explicit UTF-8 text and binary kinds without changing any bytes', async () => {
+ const f = await fixture();
+ const files = [
+ text('notes.txt', '\ufeff中文\r\ntext\r\n'),
+ text('literal.pdf', 'This user-created file is text, despite its extension.'),
+ text('control.source', 'Text with a literal NUL: \0 and form feed: \f'),
+ binary('readable.dat', encode('ASCII bytes deliberately kept as a resource')),
+ binary('image.png', Uint8Array.of(137, 80, 78, 71, 0, 255, 13, 10)),
+ ];
+ f.app.replaceProject(new f.context.ProjectModel({name: 'Kinds and bytes', files,
+ mainPath: 'notes.txt', activePath: 'readable.dat'}));
+ const expected = f.app.model.serialize();
+ const original = f.app.exportEntries();
+ const archive = await exportProject(f);
+ await importProject(f, archive);
+ assert.equal(f.app.model.serialize(), expected);
+ for (const entry of f.app.exportEntries()) {
+ assert.deepEqual(Array.from(entry.bytes), Array.from(original.find(item => item.path === entry.path).bytes), entry.path);
+ }
+ assert.equal(f.node('assetPanel').hidden, false);
+});
+
+test('older workspace ZIP metadata and third-party ZIPs continue using byte detection', async () => {
+ const f = await fixture();
+ const files = [{path: 'notes.txt', bytes: encode('ordinary UTF-8')},
+ {path: 'legacy.ps', bytes: Uint8Array.of(37, 33, 80, 83, 10, 37, 233)},
+ {path: 'image.png', bytes: Uint8Array.of(0, 255, 137)}];
+ const comment = JSON.stringify({format: 'visual-workspace', version: 1, name: 'Older project',
+ activePath: 'notes.txt', mainPath: 'legacy.ps'});
+ await importProject(f, f.context.ProjectArchive.writeZip(files, {comment}));
+ assert.equal(f.app.model.name, 'Older project');
+ assert.equal(f.app.model.mainPath, 'legacy.ps');
+ assert.equal(f.app.model.activePath, 'notes.txt');
+ assert.equal(f.app.model.get('legacy.ps').encoding, 'latin1');
+ assert.equal(f.app.model.get('image.png').kind, 'binary');
+ await importProject(f, f.context.ProjectArchive.writeZip(files, {comment: 'Third-party ZIP comment'}));
+ assert.equal(f.app.model.get('notes.txt').text, 'ordinary UTF-8');
+ assert.equal(f.app.model.get('legacy.ps').encoding, 'latin1');
+ assert.equal(f.app.model.get('image.png').kind, 'binary');
+});
+
+test('stale type metadata cannot reinterpret externally replaced or renamed ZIP members', async () => {
+ const f = await fixture();
+ f.app.replaceProject(new f.context.ProjectModel({files: [text('legacy.tex', 'café', 'latin1'), text('other.txt', 'UTF-8 中文')]}));
+ const archive = await f.context.ProjectArchive.readZip(await exportProject(f), {includeComment: true});
+ const replacement = encode('é!!'); // Same byte length, so the content binding must also be checked.
+ await importProject(f, f.context.ProjectArchive.writeZip([{path: 'legacy.tex', bytes: replacement}], {comment: archive.comment}));
+ assert.equal(f.app.model.get().encoding, 'utf-8');
+ assert.equal(f.app.model.get().text, 'é!!');
+ await importProject(f, f.context.ProjectArchive.writeZip([{path: 'renamed.tex', bytes: archive.files[0].bytes}], {comment: archive.comment}));
+ assert.equal(f.app.model.get().kind, 'binary');
+ assert.deepEqual(Array.from(f.app.exportEntries()[0].bytes), Array.from(archive.files[0].bytes));
+ await importProject(f, f.context.ProjectArchive.writeZip([...archive.files].reverse(), {comment: archive.comment}));
+ assert.equal(f.app.model.get('other.txt').text, 'UTF-8 中文');
+ assert.equal(f.app.model.get('legacy.tex').kind, 'binary');
+});
+
+test('malformed and incompatible explicit encodings fall back without replacing invalid UTF-8 bytes', async () => {
+ const f = await fixture();
+ f.app.replaceProject(new f.context.ProjectModel({files: [text('legacy.tex', 'café', 'latin1')]}));
+ const archive = await f.context.ProjectArchive.readZip(await exportProject(f), {includeComment: true});
+ const metadata = JSON.parse(archive.comment);
+ assert.ok(metadata.fileTypes?.length, 'an explicit encoding is needed for this Latin-1 TeX member');
+ const incompatible = structuredClone(metadata);
+ incompatible.fileTypes[0][1] = 'utf-8';
+ for (const fileTypes of [incompatible.fileTypes, [metadata.fileTypes[0], metadata.fileTypes[0]],
+ [[999, 'latin1', 0, 0, 0]], [['0', 'latin1', 0, 0, 0]], null, {0: metadata.fileTypes[0]}]) {
+ const comment = JSON.stringify({...metadata, fileTypes});
+ await importProject(f, f.context.ProjectArchive.writeZip(archive.files, {comment}));
+ assert.equal(f.app.model.get().kind, 'binary');
+ assert.deepEqual(Array.from(f.app.exportEntries()[0].bytes), Array.from(archive.files[0].bytes));
+ }
+});
+
+test('500 long UTF-8 paths with explicit encodings still fit the ZIP comment limit and restore every file', async () => {
+ const f = await fixture();
+ const files = Array.from({length: 500}, (_, index) => text('中'.repeat(1000) + index + '.txt', 'é', 'latin1'));
+ f.app.replaceProject(new f.context.ProjectModel({name: '文'.repeat(200), files,
+ mainPath: files[0].path, activePath: files.at(-1).path}));
+ const expected = f.app.model.serialize();
+ const archive = await exportProject(f);
+ const decoded = await f.context.ProjectArchive.readZip(archive, {includeComment: true});
+ assert.ok(encode(decoded.comment).length <= 65535);
+ await importProject(f, archive);
+ assert.ok(f.app.model.serialize() === expected, 'all 500 files and project settings survive the ZIP round-trip');
+});
From e24fcb8000acebbcda90ac230e2e23db0d15eff3 Mon Sep 17 00:00:00 2001
From: Nisconder <2129048448@qq.com>
Date: Fri, 13 Nov 2026 14:18:23 +0800
Subject: [PATCH 2/3] Fix six follow-up editing, import, and preview
regressions (#4)
---
.github/workflows/ci.yml | 6 +-
server/compiler.mjs | 9 +-
server/server.mjs | 20 ++--
src/js/main.js | 15 ++-
src/js/preview.js | 27 +++--
src/js/project-model.js | 16 ++-
src/js/workspace.js | 11 +-
tests/compiler.test.mjs | 117 +++++++++++++++++++++-
tests/editor-boundary-regression.test.mjs | 77 ++++++++++++++
tests/preview-expiry-integration.test.mjs | 43 ++++++++
tests/preview.test.mjs | 66 ++++++++++++
tests/project-model.test.mjs | 57 +++++++++++
tests/server.test.mjs | 45 +++++++++
tests/workspace-import-isolation.test.mjs | 93 +++++++++++++++++
14 files changed, 582 insertions(+), 20 deletions(-)
create mode 100644 tests/editor-boundary-regression.test.mjs
create mode 100644 tests/preview-expiry-integration.test.mjs
create mode 100644 tests/workspace-import-isolation.test.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0012c43..e908c48 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,10 @@ on:
jobs:
check:
- runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -16,6 +19,7 @@ jobs:
node-version: '22'
- name: JS 语法检查
+ shell: bash
run: |
for file in src/js/main.js src/js/workspace.js src/js/project-model.js src/js/project-archive.js src/js/preview.js src/js/visual-math.js src/js/visual-references.js src/js/visual-references-ui.js vendor/mathlive/mathlive.min.js server/compiler.mjs server/pdf-preview.mjs server/server.mjs; do
node --check "$file"
diff --git a/server/compiler.mjs b/server/compiler.mjs
index d50209e..ea089e2 100644
--- a/server/compiler.mjs
+++ b/server/compiler.mjs
@@ -237,7 +237,14 @@ export async function compileProject(project, options = {}) {
}
if (/\.eps$/i.test(mainFile)) args.push('-dEPSCrop');
args.push('-f', portable(mainFile));
- await run(engine.executable, args);
+ // Ghostscript @files avoid the Windows command-line limit without
+ // widening the per-file grants. Its parser accepts double quotes and
+ // escaped quotes, not JSON/shell backslash escapes. All paths above
+ // use forward slashes; finish each quoted argument with whitespace.
+ const argumentFile = path.join(jobDirectory, 'ghostscript.args');
+ await writeFile(argumentFile, args.map(arg => '"' + arg.replace(/"/g, '\\"') + '"\n').join(''),
+ {encoding: 'utf8', flag: 'wx'});
+ await run(engine.executable, ['@' + portable(argumentFile)]);
} else if (engine.engine === 'tectonic') {
output = path.join(outputDirectory, path.parse(mainFile).name + '.pdf');
let tectonicError;
diff --git a/server/server.mjs b/server/server.mjs
index b201746..339bb11 100644
--- a/server/server.mjs
+++ b/server/server.mjs
@@ -88,12 +88,15 @@ export function createWorkspaceServer({
jobs.delete(job.id);
}
function sweep() {
- for (const job of jobs.values()) if (!job.active && now() - job.finishedAt >= jobTTL) discard(job);
+ // Completed results expire after inactivity, so reading a long document
+ // does not lose its pages merely because compilation finished earlier.
+ for (const job of jobs.values()) if (!job.active && now() - (job.lastAccessAt ?? job.finishedAt) >= jobTTL) discard(job);
}
async function previewOperation(job, res, operation) {
if (previewActive) throw error('已有页面预览正在生成,请稍后重试。', 409, 'PREVIEW_BUSY');
const active = {job, controller: new AbortController()};
previewActive = active;
+ job.lastAccessAt = now();
const disconnected = () => { if (!res.writableEnded) active.controller.abort(); };
res.once('close', disconnected);
try {
@@ -175,7 +178,7 @@ export function createWorkspaceServer({
if (job.controller.signal.aborted) return;
Object.assign(job, {state: 'failed', error: problem.message || '编译失败', code: problem.code || 'COMPILE_FAILED',
log: String(problem.log || '').slice(-200_000)});
- }).finally(() => { job.active = false; job.finishedAt = now(); });
+ }).finally(() => { job.active = false; job.lastAccessAt = job.finishedAt = now(); });
return;
}
const match = /^\/api\/jobs\/([0-9a-f-]+)(\/pdf|\/preview(?:\/([0-9]+))?)?$/.exec(path);
@@ -195,6 +198,7 @@ export function createWorkspaceServer({
if (req.method !== 'GET') throw error('不支持的操作。', 405);
if (match[2] === '/pdf') {
if (job.state !== 'succeeded') throw error('PDF 尚未生成。', 409, 'PDF_NOT_READY');
+ job.lastAccessAt = now();
res.writeHead(200, {'Content-Type': 'application/pdf', 'Content-Length': job.pdf.length,
'Content-Disposition': 'inline; filename="preview.pdf"'});
res.end(job.pdf);
@@ -202,7 +206,7 @@ export function createWorkspaceServer({
if (job.state !== 'succeeded') throw error('PDF 尚未生成。', 409, 'PDF_NOT_READY');
const info = job.previewInfo || await previewOperation(job, res, options => previewInfo(job.pdf, options));
if (info.available) job.previewInfo = info;
- if (!match[3]) { json(200, info); return; }
+ if (!match[3]) { job.lastAccessAt = now(); json(200, info); return; }
if (!info.available) throw error(info.reason || '没有可用的预览引擎,仍可下载 PDF。', 503, 'PREVIEW_ENGINE_MISSING');
const page = Number(match[3]);
if (!Number.isSafeInteger(page) || page < 1 || page > Math.min(info.pageCount, info.maxPages)) {
@@ -212,12 +216,16 @@ export function createWorkspaceServer({
: await previewOperation(job, res, options => previewRenderer(job.pdf, page, {...options, pageCount: info.pageCount}));
job.previewCache = {page, png};
if (res.destroyed || res.writableEnded) return;
+ job.lastAccessAt = now();
res.writeHead(200, {'Content-Type': 'image/png', 'Content-Length': png.length,
'X-Preview-Page': String(page), 'X-Preview-Page-Count': String(info.pageCount)});
res.end(png);
- } else json(200, {id: job.id, state: job.state, log: job.log, error: job.error, code: job.code,
- engine: job.engine, mainPath: job.mainPath,
- ...(job.state === 'succeeded' ? {pdfUrl: '/api/jobs/' + job.id + '/pdf'} : {})});
+ } else {
+ job.lastAccessAt = now();
+ json(200, {id: job.id, state: job.state, log: job.log, error: job.error, code: job.code,
+ engine: job.engine, mainPath: job.mainPath,
+ ...(job.state === 'succeeded' ? {pdfUrl: '/api/jobs/' + job.id + '/pdf'} : {})});
+ }
return;
}
if (!['GET', 'HEAD'].includes(req.method)) throw error('不支持的操作。', 405);
diff --git a/src/js/main.js b/src/js/main.js
index e11f91a..980fe07 100644
--- a/src/js/main.js
+++ b/src/js/main.js
@@ -1043,13 +1043,26 @@ function convertNodeToLatex(node) {
}
function convertInlineNodes(node) {
+ const children = Array.from(node.childNodes);
+ const content = children.filter(child => child.nodeType !== Node.TEXT_NODE || child.textContent !== '');
+ // contenteditable keeps an unsourced