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 6319f88..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 {
@@ -162,7 +165,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});
@@ -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);
@@ -185,6 +188,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;
@@ -192,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);
@@ -199,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)) {
@@ -209,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 a195611..980fe07 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;
}
@@ -1031,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
in an emptied paragraph so the
+ // caret remains visible. Imported breaks and breaks beside text are real.
+ if (/^(p|div)$/i.test(node.tagName) && content.length === 1 &&
+ content[0].nodeType === Node.ELEMENT_NODE && content[0].tagName.toLowerCase() === 'br' &&
+ content[0].getAttribute('data-tex-source') === null) return '';
let latex = '';
let pendingComment = false;
- Array.from(node.childNodes).forEach(function(child) {
+ children.forEach(function(child) {
const part = convertNodeToLatex(child);
if (!part) return;
// 段尾注释的换行可能保存在外部空白节点中;追加文字时必须先结束注释。
if (pendingComment && !/^[ \t]*[\r\n]/.test(part)) latex += '\n';
+ else if (/^[A-Za-z@]/.test(part)) {
+ // A removed space must not turn separate atoms into \LaTeXbook.
+ // An even run of backslashes ends in a control symbol, not a word.
+ const command = /(\\+)[A-Za-z@]+$/.exec(latex);
+ if (command && command[1].length % 2 === 1) latex += '{}';
+ }
latex += part;
pendingComment = child.nodeType === Node.ELEMENT_NODE && child.classList.contains('tex-cmd') &&
child.getAttribute('hidden') !== null && part.startsWith('%') && !/[\r\n]/.test(part);
diff --git a/src/js/preview.js b/src/js/preview.js
index b1a10c7..787b250 100644
--- a/src/js/preview.js
+++ b/src/js/preview.js
@@ -41,15 +41,18 @@
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};
+ this.preview = {state: 'empty', message: '', errorCode: null, url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100};
this.previewGeneration = 0;
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();
@@ -125,7 +148,7 @@
releasePreview() {
this.stopPreview();
if (this.preview.url) this.urls.revokeObjectURL(this.preview.url);
- this.preview = {state: 'empty', message: '', url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100};
+ this.preview = {state: 'empty', message: '', errorCode: null, url: null, key: null, targetKey: null, page: 0, pageCount: 0, maxPages: 100};
}
previewSession(result) {
this.stopPreview();
@@ -143,13 +166,23 @@
if (response.status === 409 && body.code === 'PREVIEW_BUSY' && attempt < 2) {
await this.sleep(400, signal); continue;
}
- throw new Error(body.error || '页面预览暂时不可用;仍可导出 PDF。');
+ throw Object.assign(new Error(body.error || '页面预览暂时不可用;仍可导出 PDF。'), {code: body.code});
}
}
+ async retryPreview() {
+ if (!this.result || this.busy || this.disposed) return false;
+ const result = this.result, generation = this.generation;
+ if (this.preview.errorCode !== 'JOB_NOT_FOUND' && await this.loadPreview(result)) return true;
+ if (this.disposed || generation !== this.generation || this.result !== result || this.preview.errorCode !== 'JOB_NOT_FOUND') return false;
+ // Rebuild an evicted result only in response to an explicit retry.
+ // compile() snapshots the current source and guards project changes.
+ if (!await this.compile()) return false;
+ return this.previewTask;
+ }
async loadPreview(result = this.result) {
if (!result?.jobId || this.disposed) return false;
const session = this.previewSession(result);
- Object.assign(this.preview, {state: 'loading', message: 'PDF 已生成,正在准备页面预览…', targetKey: result.key, pageCount: 0});
+ Object.assign(this.preview, {state: 'loading', message: 'PDF 已生成,正在准备页面预览…', errorCode: null, targetKey: result.key, pageCount: 0});
this.emit();
try {
const response = await this.previewResponse('/api/jobs/' + encodeURIComponent(result.jobId) + '/preview', session.abort.signal);
@@ -167,6 +200,7 @@
} catch (error) {
if (!session.current()) return false;
this.preview.state = 'failed';
+ this.preview.errorCode = error.code || null;
this.preview.message = '页面预览失败:' + error.message + ' PDF 仍可导出。';
this.emit(); return false;
} finally { if (session.current()) this.previewAbort = null; }
@@ -177,6 +211,7 @@
page > Math.min(this.preview.pageCount, this.preview.maxPages) || this.disposed) return false;
const session = this.previewSession(result);
this.preview.state = 'loading';
+ this.preview.errorCode = null;
this.preview.message = '正在加载第 ' + page + ' 页…';
this.emit();
try {
@@ -191,7 +226,8 @@
} catch (error) {
if (!session.current()) return false;
this.preview.state = 'failed';
- this.preview.message = '页面加载失败:' + error.message + ' 可重试或导出 PDF。';
+ this.preview.errorCode = error.code || null;
+ this.preview.message = '页面加载失败:' + error.message + (error.code === 'JOB_NOT_FOUND' ? ' 可重新编译预览或导出已有 PDF。' : ' 可重试或导出 PDF。');
this.emit(); return false;
} finally { if (session.current()) this.previewAbort = null; }
}
@@ -215,14 +251,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 +314,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; }
}
}
@@ -379,6 +421,8 @@
$('btnPreviousPage').disabled = page.state === 'loading' || currentPage <= 1;
$('btnNextPage').disabled = page.state === 'loading' || !currentPage || currentPage >= limit;
$('btnRetryPage').hidden = !controller.result || !['failed', 'unavailable', 'empty'].includes(page.state);
+ $('btnRetryPage').disabled = controller.busy;
+ $('btnRetryPage').textContent = page.errorCode === 'JOB_NOT_FOUND' ? '重新编译预览' : '重试预览';
$('previewStale').hidden = !controller.stale && !(showingImage && page.key !== controller.result.key);
$('previewStale').textContent = showingImage && page.key !== controller.result.key
? '当前仍显示上一次编译的页面,正在加载新结果。' : '当前显示上一次结果;请重新编译以反映最新源码。';
@@ -425,7 +469,7 @@
$('btnCompilerRetry').addEventListener('click', () => controller.connect());
$('btnPreviousPage').addEventListener('click', () => { controller.previewTask = controller.loadPage(controller.preview.page - 1); });
$('btnNextPage').addEventListener('click', () => { controller.previewTask = controller.loadPage(controller.preview.page + 1); });
- $('btnRetryPage').addEventListener('click', () => { controller.previewTask = controller.loadPreview(); });
+ $('btnRetryPage').addEventListener('click', () => { controller.previewTask = controller.retryPreview(); });
document.addEventListener('keydown', event => {
if (event.defaultPrevented || event.target?.closest?.('[role="dialog"]')) return;
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
diff --git a/src/js/project-model.js b/src/js/project-model.js
index 2053835..0c5b736 100644
--- a/src/js/project-model.js
+++ b/src/js/project-model.js
@@ -26,6 +26,17 @@
const pathKey = path => path.normalize('NFC').toLowerCase();
+ function reserveFilePath(path, names) {
+ const key = pathKey(path);
+ if (names.has(key)) throw new Error('文件已存在:' + path);
+ for (const name of names) {
+ if (key.startsWith(name + '/') || name.startsWith(key + '/')) {
+ throw new Error('文件与目录路径冲突:' + path);
+ }
+ }
+ names.add(key);
+ }
+
function checkText(text, encoding) {
if (typeof text !== 'string') throw new Error('文本文件必须提供字符串内容');
if (encoding === 'latin1') {
@@ -203,8 +214,7 @@
let size = this.totalBytes;
const records = files.map(input => {
const record = normalizeFile(input);
- if (names.has(pathKey(record.file.path))) throw new Error('文件已存在:' + record.file.path);
- names.add(pathKey(record.file.path));
+ reserveFilePath(record.file.path, names);
size += record.size;
if (size > MAX_TOTAL_BYTES) throw new Error('项目大小不能超过 50 MB');
return record;
@@ -224,8 +234,8 @@
const file = this._files.get(oldPath);
if (!file) throw new Error('文件不存在:' + oldPath);
const path = validatePath(nextPath);
- if (Array.from(this._files.keys()).some(name => name !== oldPath && pathKey(name) === pathKey(path))) throw new Error('文件已存在:' + path);
if (oldPath === path) return this.get(path);
+ reserveFilePath(path, new Set(Array.from(this._files.keys()).filter(name => name !== oldPath).map(pathKey)));
this._files = new Map(Array.from(this._files, ([name, entry]) => name === oldPath ? [path, {...entry, path}] : [name, entry]));
this._sizes.set(path, this._sizes.get(oldPath));
this._sizes.delete(oldPath);
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..8765b6f 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 已导出,包含所有源码和资源文件');
});
@@ -619,8 +678,17 @@ function initWorkspace() {
return entries;
}
$('importFiles').addEventListener('change', safely(async event => {
+ const project = app.model;
app.sync();
- const entries = await readFiles(Array.from(event.target.files), false);
+ let entries;
+ try { entries = await readFiles(Array.from(event.target.files), false); }
+ catch (error) {
+ if (app.model === project) throw error;
+ return;
+ }
+ // A project replacement invalidates pending imports, including failures.
+ // Neither files nor status from the old project belong in the new one.
+ if (app.model !== project) return;
// Reading files is asynchronous: retain any edits made while waiting.
// addMany validates the full batch atomically and preserves undo history.
app.sync();
@@ -652,7 +720,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/compiler.test.mjs b/tests/compiler.test.mjs
index edbabdd..40c930f 100644
--- a/tests/compiler.test.mjs
+++ b/tests/compiler.test.mjs
@@ -25,6 +25,33 @@ function emitPdf(args, options) {
return writeFile(target, pdf);
}
+// Independently decode the quoted arguments accepted by Ghostscript @files.
+// Unlike JSON or a shell, only backslash-double-quote is an escape here.
+function parseResponseArguments(contents) {
+ const args = [];
+ let token = '', quoted = false, started = false;
+ for (let i = 0; i < contents.length; i++) {
+ const character = contents[i];
+ if (character === '\\' && contents[i + 1] === '"') { token += '"'; i++; started = true; }
+ else if (character === '"') { quoted = !quoted; started = true; }
+ else if (/[ \t\r\n\f\v]/.test(character) && !quoted) {
+ if (started) args.push(token);
+ token = ''; started = false;
+ } else { token += character; started = true; }
+ }
+ if (quoted) throw new Error('Unterminated response-file quote');
+ if (started) args.push(token);
+ return args;
+}
+async function expandGhostscriptArguments(args) {
+ const expanded = [];
+ for (const arg of args) {
+ if (arg.startsWith('@')) expanded.push(...parseResponseArguments(await readFile(arg.slice(1), 'utf8')));
+ else expanded.push(arg);
+ }
+ return expanded;
+}
+
test('engine discovery uses explicit executable paths and reports missing engines', async t => {
const directory = await root(t);
const binary = path.join(directory, 'engine with spaces' + (process.platform === 'win32' ? '.exe' : ''));
@@ -95,6 +122,7 @@ test('PS and binary EPS render through Ghostscript pdfwrite with SAFER and appro
const result = await compileProject({...project([source]), mainPath: null}, {kind: 'postscript', mainPath: source.path, engines, temporaryParent,
runner: async (command, args, options) => {
assert.equal(command, engines.postscript.executable);
+ args = await expandGhostscriptArguments(args);
for (const flag of ['-dSAFER', '-dBATCH', '-dNOPAUSE', '-sDEVICE=pdfwrite', '-f']) assert.ok(args.includes(flag));
assert.equal(args.includes('-dEPSCrop'), extension === 'eps');
assert.ok(args.some(arg => arg.startsWith('--permit-file-read=')));
@@ -113,6 +141,7 @@ test('PostScript permits staged resources by relative filename without granting
const temporaryParent = await root(t);
await compileProject(project([text('drawing.ps', '(parts/shared.ps) run'), text('parts/shared.ps', 'showpage')]), {
kind: 'postscript', engines, temporaryParent, runner: async (_, args, options) => {
+ args = await expandGhostscriptArguments(args);
assert.ok(args.includes('--permit-file-read=parts/shared.ps'));
assert.ok(args.includes('--permit-file-read=./parts/shared.ps'));
assert.ok(!args.includes('--permit-file-read=*'));
@@ -127,6 +156,92 @@ test('PostScript permits staged resources by relative filename without granting
assert.equal(invoked, false, 'a filename cannot add an extra Ghostscript permission');
});
+test('a maximum-file PostScript project starts a real subprocess with bounded arguments and all resource permissions', async t => {
+ const temporaryParent = await root(t);
+ const probe = path.join(temporaryParent, 'mock ghostscript.mjs');
+ await writeFile(probe, `import {readFile, writeFile} from 'node:fs/promises';
+${parseResponseArguments.toString()}
+${expandGhostscriptArguments.toString()}
+const args = await expandGhostscriptArguments(process.argv.slice(2));
+const output = args.find(arg => arg.startsWith('-sOutputFile=')).slice('-sOutputFile='.length);
+await writeFile(output, '%PDF-1.7\\nfixture\\n%%EOF');
+process.stdout.write(JSON.stringify(args));
+`);
+ const files = [text('docs/drawing.ps', 'showpage')];
+ for (let i = 0; i < 499; i++) files.push(text('assets/resource-' + i + '-' + 'x'.repeat(130) + '.txt', 'resource'));
+ let commandLength;
+ const result = await compileProject(project(files), {kind: 'postscript', engines, temporaryParent,
+ runner: async (_, args, options) => {
+ // The old per-file argv exceeds CreateProcess's limit on Windows.
+ const native = await runProcess(process.execPath, [probe, ...args], options);
+ assert.equal(native.code, 0, native.log);
+ commandLength = [process.execPath, probe, ...args].join(' ').length;
+ assert.ok(commandLength < 4096, 'the native command must not grow with project file count');
+ const expanded = JSON.parse(native.log);
+ assert.ok(expanded.includes('-dSAFER'));
+ assert.ok(!expanded.includes('-dNOSAFER'));
+ assert.ok(!expanded.includes('--permit-file-read=*'));
+ const readGrants = expanded.filter(arg => arg.startsWith('--permit-file-read='));
+ assert.equal(readGrants.length, 1 + 2 * files.length);
+ for (const file of files) {
+ const relative = path.relative(options.cwd, path.join(options.sourceDirectory, file.path)).replace(/\\/g, '/');
+ assert.ok(readGrants.includes('--permit-file-read=' + relative), file.path);
+ assert.ok(readGrants.includes('--permit-file-read=./' + relative), file.path);
+ }
+ return {...native, log: 'Real subprocess started'};
+ }});
+ assert.deepEqual(result.pdf, pdf);
+ assert.deepEqual(await readdir(temporaryParent), ['mock ghostscript.mjs'], 'staged files and argument file are removed');
+});
+
+test('PostScript argument files preserve special paths without adding switches or permitting extra files', async t => {
+ const directory = await root(t);
+ const temporaryParent = path.join(directory, "参数 # @ O'Brien");
+ await mkdir(temporaryParent);
+ const files = [text('docs/图形 main.eps', '%!PS\nshowpage'), text("assets/O'Brien # @ -dNOSAFER.ps", 'resource'),
+ text('assets/@extra.args', '-dNOSAFER'), text('assets/-dNOSAFER', 'resource'), text('assets/space\u00a0name.ps', 'resource')];
+ await compileProject(project(files), {kind: 'postscript', engines, temporaryParent,
+ runner: async (_, args, options) => {
+ assert.equal(args.length, 1);
+ assert.ok(args[0].startsWith('@'));
+ assert.equal(path.dirname(path.resolve(args[0].slice(1))), path.dirname(options.sourceDirectory), 'arguments stay outside user project files');
+ const expanded = await expandGhostscriptArguments(args);
+ assert.ok(expanded.includes('-dSAFER'));
+ assert.ok(expanded.includes('-dEPSCrop'));
+ assert.ok(!expanded.includes('-dNOSAFER'));
+ assert.ok(!expanded.some(arg => arg.startsWith('@')));
+ assert.deepEqual(expanded.slice(-2), ['-f', path.join(options.sourceDirectory, files[0].path).replace(/\\/g, '/')]);
+ const grants = expanded.filter(arg => arg.startsWith('--permit-file-read='));
+ assert.deepEqual(grants, [
+ '--permit-file-read=' + options.sourceDirectory.replace(/\\/g, '/') + '/*',
+ ...files.flatMap(file => {
+ const relative = path.relative(options.cwd, path.join(options.sourceDirectory, file.path)).replace(/\\/g, '/');
+ return ['--permit-file-read=' + relative, '--permit-file-read=./' + relative];
+ })
+ ]);
+ await emitPdf(expanded, options);
+ return {code: 0};
+ }});
+ assert.deepEqual(await readdir(temporaryParent), []);
+});
+
+test('PostScript argument files are cleaned after engine failure and cancellation', async t => {
+ const temporaryParent = await root(t);
+ for (const cancelled of [false, true]) {
+ const controller = new AbortController();
+ await assert.rejects(compileProject(project([text('drawing.ps', 'showpage')]), {
+ kind: 'postscript', engines, temporaryParent, signal: controller.signal,
+ runner: async (_, args) => {
+ assert.equal(args.length, 1);
+ await access(args[0].slice(1));
+ if (cancelled) controller.abort();
+ return {code: 1, log: 'Engine failed'};
+ }
+ }), {code: cancelled ? 'CANCELLED' : 'COMPILE_FAILED'});
+ assert.deepEqual(await readdir(temporaryParent), []);
+ }
+});
+
test('Tectonic Biber projects never return a PDF with incomplete bibliography as success', async t => {
const temporaryParent = await root(t);
await assert.rejects(compileProject(project([text('main.tex', 'biblatex with Biber')]), {
@@ -150,7 +265,7 @@ test('filesystem-unsafe paths, directory collisions and wrong main kinds are rej
for (const file of ['../escape.tex', 'C:/escape.tex', 'NUL.tex', 'trailing. /main.tex', 'dir*/main.tex']) {
await assert.rejects(compileProject(project([text(file, 'text')]), options));
}
- await assert.rejects(compileProject(project([text('main.tex', 'x'), text('dir', 'file'), text('dir/child.tex', 'child')]), options), {code: 'INVALID_PATH'});
+ await assert.rejects(compileProject(project([text('main.tex', 'x'), text('dir', 'file'), text('dir/child.tex', 'child')]), options), {code: 'INVALID_PROJECT'});
await assert.rejects(compileProject(project([text('notes.txt', 'x')]), options), {code: 'INVALID_MAIN'});
await assert.rejects(compileProject(project([text('main.tex', 'x')]), {...options, kind: 'postscript'}), {code: 'INVALID_MAIN'});
assert.equal(calls, 0);
diff --git a/tests/editor-boundary-regression.test.mjs b/tests/editor-boundary-regression.test.mjs
new file mode 100644
index 0000000..fa24f67
--- /dev/null
+++ b/tests/editor-boundary-regression.test.mjs
@@ -0,0 +1,77 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {fixture} from './helpers/workspace-fixture.mjs';
+
+async function visual(body) {
+ const f = await fixture();
+ const source = '\\documentclass{article}\n\\begin{document}\n' + body + '\n\\end{document}\n';
+ await f.input('sourceArea', source);
+ await f.click('modeVisual'); f.refresh();
+ assert.equal(f.app.model.get().text, source, 'opening visual mode must preserve the original source');
+ return {f, source};
+}
+
+test('deleting the space after an imported control word cannot merge the following text into its name', async () => {
+ for (const body of [String.raw`\LaTeX book`, String.raw`\section{\LaTeX book}`,
+ String.raw`\textbf{\LaTeX book}`, String.raw`\begin{itemize}\item \LaTeX book\end{itemize}`]) {
+ const {f} = await visual(body);
+ const command = f.node('editorArea').querySelectorAll('.tex-cmd')
+ .find(node => node.getAttribute('data-tex-source') === String.raw`\LaTeX`);
+ const siblings = command.parentNode.childNodes;
+ const following = siblings[siblings.indexOf(command) + 1];
+ assert.equal(following.textContent, ' book');
+ following.textContent = 'book';
+ await f.emit(f.node('editorArea'), 'input');
+ await f.click('modeSource');
+ assert.ok(f.app.model.get().text.includes(String.raw`\LaTeX{}book`));
+ assert.doesNotMatch(f.app.model.get().text, /\\LaTeXbook/);
+ await f.click('modeVisual'); f.refresh();
+ assert.ok(f.node('editorArea').querySelectorAll('.tex-cmd')
+ .some(node => node.getAttribute('data-tex-source') === String.raw`\LaTeX{}`));
+ }
+});
+
+test('empty paragraph placeholders disappear from exported source without creating a line-break command', async () => {
+ for (const tag of ['p', 'div']) {
+ const {f, source} = await visual('Alpha\n\nBeta');
+ const paragraph = f.node('editorArea').querySelector('p');
+ if (tag === 'div') paragraph.tagName = 'DIV';
+ // Chromium leaves this placeholder after the user deletes all paragraph text.
+ paragraph.innerHTML = '
';
+ paragraph.appendChild(f.document.createTextNode(''));
+ await f.emit(f.node('editorArea'), 'input');
+ await f.click('modeSource');
+ assert.equal(f.app.model.get().text, source.replace('Alpha', ''));
+ assert.doesNotMatch(f.app.model.get().text, /\\\\/);
+ }
+});
+
+test('actual imported and newly inserted line breaks survive the placeholder check', async () => {
+ const {f, source} = await visual(String.raw`Alpha\\[2pt]Beta`);
+ const paragraph = f.node('editorArea').querySelector('p');
+ paragraph.childNodes[0].textContent = 'Changed';
+ await f.emit(f.node('editorArea'), 'input');
+ await f.click('modeSource');
+ assert.equal(f.app.model.get().text, source.replace('Alpha', 'Changed'));
+ await f.click('modeVisual'); f.refresh();
+ f.node('editorArea').querySelector('p').innerHTML = 'First
Last
';
+ await f.emit(f.node('editorArea'), 'input');
+ await f.click('modeSource');
+ assert.ok(f.app.model.get().text.includes('First\\\\\nLast\\\\\n'));
+
+ const untouched = await visual(String.raw`\\*[1ex]`);
+ await untouched.f.click('modeSource');
+ assert.equal(untouched.f.app.model.get().text, untouched.source, 'an imported lone break owns real TeX source');
+});
+
+test('unchanged source and existing command separators remain exact after neighboring edits', async () => {
+ const body = String.raw`Before \LaTeX book, \LaTeX-book, \LaTeX{}book and \\LaTeX.`;
+ const {f, source} = await visual(body);
+ await f.click('modeSource');
+ assert.equal(f.app.model.get().text, source);
+ await f.click('modeVisual'); f.refresh();
+ f.node('editorArea').querySelector('p').childNodes[0].textContent = 'After ';
+ await f.emit(f.node('editorArea'), 'input');
+ await f.click('modeSource');
+ assert.equal(f.app.model.get().text, source.replace('Before ', 'After '));
+});
diff --git a/tests/image-label-regression.test.mjs b/tests/image-label-regression.test.mjs
new file mode 100644
index 0000000..6656773
--- /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('../src/js/' + 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-expiry-integration.test.mjs b/tests/preview-expiry-integration.test.mjs
new file mode 100644
index 0000000..d893d4f
--- /dev/null
+++ b/tests/preview-expiry-integration.test.mjs
@@ -0,0 +1,43 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import vm from 'node:vm';
+import {createWorkspaceServer} from '../server/server.mjs';
+
+test('real HTTP preview survives active reading and recovers an idle eviction on explicit retry', async t => {
+ let clock = 1000, compilations = 0, downloads = 0, urlId = 0;
+ const request = {kind: 'latex', mainPath: 'main.tex', project: {files: [{path: 'main.tex', kind: 'text', text: 'Source'}]}};
+ const engines = {latex: {available: true, engine: 'fixture'}, postscript: {available: true}};
+ const server = createWorkspaceServer({now: () => clock, enginesProvider: async () => engines,
+ compiler: async () => { compilations++; return {pdf: Buffer.from('%PDF-1.7\nfixture\n%%EOF'), engine: 'fixture'}; },
+ previewInfo: async () => ({available: true, pageCount: 3, maxPages: 100}),
+ previewRenderer: async (_, page) => Buffer.from('PNG page ' + page)});
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
+ t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
+ const base = 'http://127.0.0.1:' + server.address().port;
+ const fetcher = (url, init) => fetch(base + url, init);
+ const urls = {createObjectURL: () => 'blob:fixture/' + ++urlId, revokeObjectURL() {}};
+ const context = vm.createContext({Blob, AbortController, setTimeout, clearTimeout, URL: urls, fetch: fetcher});
+ vm.runInContext(await readFile(new URL('../src/js/preview.js', import.meta.url), 'utf8'), context);
+ const controller = new context.CompilationController({getRequest: () => structuredClone(request),
+ fetch: fetcher, urls, protocol: 'http:', pollInterval: 1, download: () => { downloads++; }});
+ t.after(() => controller.destroy());
+ assert.equal(await controller.connect(), true);
+ assert.equal(await controller.compile(), true);
+ assert.equal(await controller.previewTask, true);
+ const previousJob = controller.result.jobId;
+ for (let minute = 2; minute <= 12; minute += 2) {
+ clock = 1000 + minute * 60_000;
+ assert.equal(await controller.loadPage(1 + (minute / 2) % 3), true);
+ }
+ clock += 10 * 60_000;
+ assert.equal(await controller.loadPage(2), false);
+ assert.equal(controller.preview.errorCode, 'JOB_NOT_FOUND');
+ assert.equal(await controller.exportPdf(), true);
+ assert.equal(downloads, 1);
+ assert.equal(compilations, 1, 'expiry and cached export never start a compiler');
+ assert.equal(await controller.retryPreview(), true);
+ assert.notEqual(controller.result.jobId, previousJob);
+ assert.equal(controller.preview.state, 'ready');
+ assert.equal(compilations, 2);
+});
diff --git a/tests/preview-integration.test.mjs b/tests/preview-integration.test.mjs
new file mode 100644
index 0000000..408bb98
--- /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/server.mjs';
+
+const code = readFileSync(new URL('../src/js/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..0506737 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);
});
@@ -444,6 +508,72 @@ test('missing Ghostscript falls back without invalidating a successful downloada
assert.equal(await f.controller.exportPdf(), true);
});
+test('the retry control recompiles an expired result and restores page images', async () => {
+ let expired = false;
+ const f = await workspaceFixture({route: async url => {
+ if (expired && url.startsWith('/api/jobs/job-1/preview')) return json({code: 'JOB_NOT_FOUND', error: 'Result expired'}, 404);
+ if (url.endsWith('/preview')) return json({available: true, pageCount: 3, maxPages: 100});
+ if (/\/preview\/\d+$/.test(url)) return {ok: true, blob: async () => new Blob(['png'], {type: 'image/png'})};
+ }});
+ await f.controller.compile(); await f.controller.previewTask;
+ const previousPdf = f.controller.result.url;
+ const downloads = [];
+ f.controller.download = url => downloads.push(url);
+ expired = true;
+ assert.equal(await f.controller.loadPage(2), false);
+ assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 1, 'passive page failure does not start a compiler');
+ assert.equal(await f.controller.exportPdf(), true, 'cached PDF remains downloadable after server expiry');
+ assert.equal(downloads[0], previousPdf);
+ assert.equal(f.ids.get('btnRetryPage').hidden, false);
+ f.project.files[0].text += '\nLatest edit'; f.app.onChange();
+ f.ids.get('btnRetryPage').emit('click');
+ assert.equal(await f.controller.previewTask, true);
+ assert.equal(f.controller.result.jobId, 'job-2');
+ assert.equal(f.controller.preview.state, 'ready');
+ assert.equal(f.ids.get('pdfPageView').hidden, false);
+ const requests = f.calls.filter(call => call.url === '/api/compile');
+ assert.equal(requests.length, 2);
+ assert.match(JSON.parse(requests[1].init.body).project.files[0].text, /Latest edit$/);
+ f.controller.destroy();
+});
+
+test('retry discovers expired preview metadata but ordinary renderer failures do not recompile', async () => {
+ for (const code of ['JOB_NOT_FOUND', 'PREVIEW_FAILED']) {
+ let failed = true;
+ const f = await workspaceFixture({route: async url => {
+ if (url.endsWith('/preview')) {
+ if (failed) return json({code, error: 'Preview failed'}, code === 'JOB_NOT_FOUND' ? 404 : 500);
+ return json({available: true, pageCount: 1});
+ }
+ if (url.endsWith('/preview/1')) return {ok: true, blob: async () => new Blob(['png'], {type: 'image/png'})};
+ }});
+ await f.controller.compile(); await f.controller.previewTask;
+ failed = false;
+ f.ids.get('btnRetryPage').emit('click');
+ assert.equal(await f.controller.previewTask, true);
+ assert.equal(f.calls.filter(call => call.url === '/api/compile').length, code === 'JOB_NOT_FOUND' ? 2 : 1);
+ f.controller.destroy();
+ }
+});
+
+test('a late expiry response after cancelling retry cannot restart compilation', async () => {
+ const pending = deferred(), started = deferred();
+ let hold = false;
+ const f = await workspaceFixture({route: async url => {
+ if (hold && url.endsWith('/preview')) { started.resolve(); return pending.promise; }
+ }});
+ await f.controller.compile(); await f.controller.previewTask;
+ hold = true;
+ f.ids.get('btnRetryPage').emit('click');
+ const retrying = f.controller.previewTask;
+ await started.promise;
+ await f.controller.cancel();
+ pending.resolve(json({code: 'JOB_NOT_FOUND', error: 'Result expired'}, 404));
+ assert.equal(await retrying, false);
+ assert.equal(f.calls.filter(call => call.url === '/api/compile').length, 1);
+ f.controller.destroy();
+});
+
test('a delayed page from a previous project cannot create an image after the project is cleared', async () => {
const page = deferred(), started = deferred();
const f = fixture({route: async url => {
diff --git a/tests/project-model.test.mjs b/tests/project-model.test.mjs
index ba16797..308486e 100644
--- a/tests/project-model.test.mjs
+++ b/tests/project-model.test.mjs
@@ -58,6 +58,63 @@ test('batch import validates everything before adding any file', () => {
assert.equal(model.list().length, 1);
});
+test('construction and deserialization reject file-directory conflicts in both orders with case and NFC normalization', () => {
+ for (const [parent, child] of [['sections', 'sections/intro.tex'], ['Sections', 'sections/intro.tex'],
+ ['café', 'cafe\u0301/intro.tex'], ['folder', 'FOLDER\\nested\\intro.tex']]) {
+ for (const paths of [[parent, child], [child, parent]]) {
+ const files = paths.map(path => textFile(path, 'content'));
+ assert.throws(() => new ProjectModel({files}), /文件与目录路径冲突/);
+ assert.throws(() => ProjectModel.deserialize({format: 'visual-workspace', version: 1,
+ name: 'Conflicting project', files, mainPath: paths[0], activePath: paths[0]}), /文件与目录路径冲突/);
+ }
+ }
+});
+
+test('failed batch path conflicts leave files, sizes, current paths and undo/redo untouched', () => {
+ for (const [existing, conflict] of [['sections', 'sections/chapter.tex'], ['sections/chapter.tex', 'SECTIONS']]) {
+ const model = new ProjectModel({files: [textFile('main.tex', 'original'), textFile(existing, 'existing')]});
+ model.update('first edit'); model.update('second edit'); model.undo();
+ const expected = model.serialize();
+ const size = model.totalBytes;
+ assert.throws(() => model.addMany([textFile('would-have-been-added.txt', 'new'), textFile(conflict)]), /文件与目录路径冲突/);
+ assert.equal(model.serialize(), expected);
+ assert.equal(model.totalBytes, size);
+ assert.equal(model.redo().text, 'second edit');
+ assert.equal(model.undo().text, 'first edit');
+ assert.equal(model.undo().text, 'original');
+ }
+ const empty = new ProjectModel();
+ assert.throws(() => empty.addMany([textFile('a/b.tex'), textFile('A')]), /文件与目录路径冲突/);
+ assert.equal(empty.list().length, 0);
+ assert.equal(empty.totalBytes, 0);
+});
+
+test('conflicting renames fail atomically while preserving active/main paths and edit history', () => {
+ for (const [existing, renamed] of [['Sections', 'sections/main.tex'], ['café/chapter.tex', 'cafe\u0301']]) {
+ const model = new ProjectModel({files: [textFile('main.tex', 'original'), textFile(existing, 'existing')]});
+ model.update('edited');
+ const expected = model.serialize();
+ const size = model.totalBytes;
+ assert.throws(() => model.rename('main.tex', renamed), /文件与目录路径冲突/);
+ assert.equal(model.serialize(), expected);
+ assert.equal(model.totalBytes, size);
+ assert.equal(model.undo().text, 'original');
+ assert.equal(model.redo().text, 'edited');
+ }
+});
+
+test('valid siblings, similar path prefixes, case-only renames and moving a file under its old name remain supported', () => {
+ const model = new ProjectModel({files: [textFile('notes', 'old'), textFile('notes-extra/chapter.tex'),
+ textFile('shared/one.tex'), textFile('shared/two.tex')]});
+ model.update('edited');
+ model.rename('notes', 'NOTES');
+ model.rename('NOTES', 'notes/document.txt');
+ assert.equal(model.activePath, 'notes/document.txt');
+ assert.equal(model.mainPath, 'notes/document.txt');
+ assert.equal(model.undo().text, 'old');
+ assert.equal(model.list().length, 4);
+});
+
test('file mutations update active and main paths while keeping text/history', () => {
const model = new ProjectModel({files: [textFile('main.tex', 'old'), textFile('refs.bib', 'bib')]});
model.update('new');
diff --git a/tests/server.test.mjs b/tests/server.test.mjs
index 083b405..34364fb 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);
});
@@ -153,6 +158,51 @@ test('expired results are removed and invalid engine output is rejected', async
assert.equal((await invalid.completed(bad.id)).code, 'INVALID_PDF');
});
+test('reading pages renews result lifetime while idle results still expire', async t => {
+ let clock = 1;
+ const f = await fixture(t, {now: () => clock, jobTTL: 10,
+ previewInfo: async () => ({available: true, pageCount: 3, maxPages: 100}),
+ previewRenderer: async (_, page) => Buffer.from('PNG page ' + page)});
+ const {id} = await (await f.compile()).json(); await f.completed(id);
+ const route = '/api/jobs/' + id;
+ for (clock of [7, 13, 19, 25]) {
+ assert.equal((await f.call(route + '/preview/2')).status, 200, 'active readers retain both cached and rendered pages');
+ }
+ clock = 34;
+ assert.equal((await f.call(route + '/pdf')).status, 200, 'PDF downloads also count as use');
+ clock = 43;
+ assert.equal((await fetch(f.url + route)).status, 403, 'unauthenticated traffic cannot extend a result');
+ clock = 44;
+ assert.equal((await f.call(route)).status, 404, 'an idle result is still removed at the TTL');
+});
+
+test('a page render started near expiry renews the job before asynchronous rendering', async t => {
+ let clock = 1, finish, started, aborted = false;
+ const rendering = new Promise(resolve => { started = resolve; });
+ const f = await fixture(t, {now: () => clock, jobTTL: 10,
+ previewInfo: async () => ({available: true, pageCount: 1, maxPages: 100}),
+ previewRenderer: (_, __, {signal}) => new Promise(resolve => {
+ finish = () => resolve(Buffer.from('PNG page'));
+ signal.addEventListener('abort', () => { aborted = true; finish(); }, {once: true});
+ started();
+ })});
+ t.after(() => finish?.());
+ const {id} = await (await f.compile()).json(); await f.completed(id);
+ const route = '/api/jobs/' + id;
+ await f.call(route + '/preview');
+ clock = 9;
+ const pending = f.call(route + '/preview/1');
+ await rendering;
+ clock = 11;
+ await f.call('/api/status'); // Triggers a sweep without accessing this job.
+ assert.equal(aborted, false, 'the active request has already renewed its job');
+ clock = 18;
+ finish();
+ assert.equal((await pending).status, 200);
+ clock = 28;
+ assert.equal((await f.call(route)).status, 404, 'completion renews once but does not retain the job forever');
+});
+
test('authenticated preview metadata and PNG pages are cached without delaying PDF downloads', async t => {
let metadataCalls = 0;
const pages = [];
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');
+});
diff --git a/tests/workspace-import-isolation.test.mjs b/tests/workspace-import-isolation.test.mjs
new file mode 100644
index 0000000..2c76ece
--- /dev/null
+++ b/tests/workspace-import-isolation.test.mjs
@@ -0,0 +1,93 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {fixture} from './helpers/workspace-fixture.mjs';
+
+const encode = source => new TextEncoder().encode(source);
+
+for (const replacement of ['new project', 'imported project']) {
+ test('pending Add Files cannot enter a later ' + replacement, async () => {
+ const f = await fixture();
+ const previousModel = f.app.model;
+ let finishReading;
+ const bytes = encode('This belongs to the old project.');
+ f.node('importFiles').files = [{name: 'old-project-only.txt', size: bytes.length,
+ arrayBuffer: () => new Promise(resolve => { finishReading = resolve; })}];
+ const importing = f.emit(f.node('importFiles'), 'change');
+ if (replacement === 'new project') await f.click('btnNewProject');
+ else {
+ const project = JSON.stringify({format: 'visual-workspace', version: 1, name: 'Replacement',
+ files: [{path: 'main.tex', kind: 'text', text: 'Imported project.'}], activePath: 'main.tex', mainPath: 'main.tex'});
+ f.node('importProject').files = [{name: 'replacement.vlproj', size: project.length, text: async () => project}];
+ await f.emit(f.node('importProject'), 'change');
+ }
+ assert.notEqual(f.app.model, previousModel);
+ await f.input('sourceArea', 'The replacement project has its own edits.');
+ f.app.persist();
+ const expected = f.app.model.serialize();
+ const saved = f.storage.get('visuallatex_workspace_v1');
+ const status = f.node('statusText').textContent;
+ finishReading(bytes.buffer);
+ await importing;
+ assert.equal(f.app.model.serialize(), expected);
+ assert.equal(f.storage.get('visuallatex_workspace_v1'), saved);
+ assert.equal(f.node('sourceArea').value, 'The replacement project has its own edits.');
+ assert.equal(f.node('statusText').textContent, status);
+ assert.equal(previousModel.get('old-project-only.txt'), null);
+ });
+}
+
+test('failed reads from a replaced project do not overwrite the current project status', async () => {
+ const f = await fixture();
+ let rejectReading;
+ f.node('importFiles').files = [{name: 'unavailable.txt', size: 10,
+ arrayBuffer: () => new Promise((resolve, reject) => { rejectReading = reject; })}];
+ const importing = f.emit(f.node('importFiles'), 'change');
+ await f.click('btnNewProject');
+ f.node('statusText').textContent = 'Current project status';
+ const expected = f.app.model.serialize();
+ rejectReading(new Error('Old file read failed'));
+ await importing;
+ assert.equal(f.app.model.serialize(), expected);
+ assert.equal(f.node('statusText').textContent, 'Current project status');
+});
+
+test('Add Files still reports read failures for its current project', async () => {
+ const f = await fixture();
+ const expected = f.app.model.serialize();
+ f.node('importFiles').files = [{name: 'unavailable.txt', size: 10,
+ arrayBuffer: async () => { throw new Error('Current file read failed'); }}];
+ await f.emit(f.node('importFiles'), 'change');
+ assert.equal(f.app.model.serialize(), expected);
+ assert.equal(f.node('statusText').textContent, 'Current file read failed');
+});
+
+test('new files, renamed files and ZIP imports reject file-directory conflicts before changing the draft', async () => {
+ const f = await fixture();
+ f.prompts.push('sections/intro.tex');
+ await f.click('newFile');
+ await f.input('sourceArea', 'An existing chapter.');
+ f.app.persist();
+ const expected = f.app.model.serialize();
+ const saved = f.storage.get('visuallatex_workspace_v1');
+ f.prompts.push('SECTIONS');
+ await f.click('newFile');
+ assert.equal(f.app.model.serialize(), expected);
+ assert.equal(f.storage.get('visuallatex_workspace_v1'), saved);
+ assert.match(f.node('statusText').textContent, /文件与目录路径冲突/);
+ f.app.switchFile('main.tex');
+ const beforeRename = f.app.model.serialize();
+ f.prompts.push('sections');
+ await f.click('renameFile');
+ assert.equal(f.app.model.serialize(), beforeRename);
+ assert.match(f.node('statusText').textContent, /文件与目录路径冲突/);
+
+ const bytes = f.context.ProjectArchive.writeZip([{path: 'notes', bytes: encode('File')},
+ {path: 'notes/chapter.tex', bytes: encode('Chapter')}]);
+ f.node('importProject').files = [{name: 'conflict.zip', size: bytes.length, arrayBuffer: async () => bytes.buffer}];
+ const beforeImport = f.storage.get('visuallatex_workspace_v1');
+ await f.emit(f.node('importProject'), 'change');
+ assert.equal(f.app.model.serialize(), beforeRename);
+ assert.equal(f.storage.get('visuallatex_workspace_v1'), beforeImport);
+ assert.match(f.node('statusText').textContent, /文件与目录路径冲突/);
+ assert.equal(f.app.model.get('sections/intro.tex').text, 'An existing chapter.');
+});